diff --git a/.cursor/rules/actor-core-rules.mdc b/.cursor/rules/actor-core-rules.mdc new file mode 100644 index 000000000..4de859b00 --- /dev/null +++ b/.cursor/rules/actor-core-rules.mdc @@ -0,0 +1,147 @@ +--- +description: ActorCore rules +globs: +alwaysApply: false +--- +# ActorCore Development Guide + +This guide contains essential information for working with the ActorCore project. + +## Project Naming and Terminology + +- Use `ActorCore` when referring to the project in documentation and plain English +- Use `actor-core` (kebab-case) when referring to the project in code, package names, and imports + +### Core Concepts + +- **Actor**: A stateful, long-lived entity that processes messages and maintains state +- **Manager**: Component responsible for creating, routing, and managing actor instances +- **Action**: Method for an actor to expose callable functions to clients +- **Event**: Asynchronous message sent from an actor to connected clients +- **Alarm**: Scheduled callback that triggers at a specific time + +## Build Commands + +- **Type Check:** `yarn check-types` - Verify TypeScript types +- **Check specific package:** `yarn check-types -F actor-core` - Check only specified package +- **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 + +## Driver Implementations + +Available driver implementations: + +- **Memory**: In-memory implementation for development and testing +- **Redis**: Production-ready implementation using Redis for persistence and pub/sub +- **Cloudflare Workers**: Uses Durable Objects for actor state persistence +- **Rivet**: Fully-managed cloud platform with built-in scaling and monitoring + +## Platform Support + +ActorCore supports multiple runtime environments: + +- **NodeJS**: Standard Node.js server environment +- **Cloudflare Workers**: Edge computing environment +- **Bun**: Fast JavaScript runtime alternative to Node.js +- **Rivet**: Cloud platform with built-in scaling and management + +## Package Import Resolution + +When importing from workspace packages, always check the package's `package.json` file under the `exports` field to determine the correct import paths: + +1. Locate the package's `package.json` file +2. Find the `exports` object which maps subpath patterns to their file locations +3. Use these defined subpaths in your imports rather than direct file paths + +## Code Style Guidelines + +- **Formatting:** Uses Biome for consistent formatting +- **Imports:** Organized imports enforced, unused imports warned +- **TypeScript:** Strict mode enabled, target ESNext +- **Naming:** + - camelCase for variables, functions + - PascalCase for classes, interfaces, types + - UPPER_CASE for constants +- **Error Handling:** + - Use `UserError` for client-safe errors + - Use `InternalError` for internal errors + +## Project Structure + +- Monorepo with Yarn workspaces and Turborepo +- Core code in `packages/actor-core/` +- Platform implementations in `packages/platforms/` +- Driver implementations in `packages/drivers/` + +## State Management + +- Each actor owns and manages its own isolated state via `c.state` +- State is automatically persisted between action calls +- State is initialized via `createState` function or `state` constant +- Only JSON-serializable types can be stored in state +- Use `onStateChange` to react to state changes + +## Authentication and Security + +- Authentication is handled through the `onBeforeConnect` lifecycle hook +- Connection state is accessed with `c.conn.state` +- Access control should be implemented for each action +- Throwing an error in `onBeforeConnect` will abort the connection +- Use `UserError` for safe error messages to clients +- Use data validation libraries like zod for input validation + +## Actions and Events + +- **Actions**: Used for clients to call actor functions +- **Events**: For actors to publish updates to clients +- Actions are defined in the `actions` object of the actor configuration +- Helper functions outside the `actions` object are not callable by clients +- Broadcasting is done via `c.broadcast(name, data)` +- Specific client messaging uses `conn.send(name, data)` +- Clients subscribe to events with `actor.on(eventName, callback)` + +## Lifecycle Hooks + +- `createState()`: Function that returns initial actor state +- `onStart(c)`: Called any time actor is started (after restart/upgrade) +- `onStateChange(c, newState)`: Called when actor state changes +- `onBeforeConnect(c)`: Called when new client connects +- `onConnect(c)`: Executed after client connection succeeds +- `onDisconnect(c)`: Called when client disconnects + +## Actor Management + +- App is configured with actors using `setup({ actors: { actorName }})` followed by `serve(app)` +- Actors are accessed by client using `client.actorName.get()` +- Actors can pass an ID parameter or object with `client.actorName.get(id)` or `client.actorName.get({key: value})` +- Actors can be shut down with `c.shutdown()` from within the actor + +## Scaling and Architecture Guidelines + +- Each actor should have a single responsibility +- Keep state minimal and relevant to the actor's core function +- Use separate actors for different entity types (users, rooms, documents) +- Avoid too many cross-actor communications +- Use appropriate topology based on your scaling needs + +## Scheduling + +- Schedule future events with `c.after(duration, fn, ...args)` +- Schedule events for specific time with `c.at(timestamp, fn, ...args)` +- Scheduled events persist across actor restarts + +## CORS Configuration + +- Configure CORS to allow cross-origin requests in production +- Set allowed origins, methods, headers, and credentials +- For development, use `cors: { origin: "http://localhost:3000" }` + +## Development Best Practices + +- Prefer functional actor pattern with `actor({ ... })` syntax +- Use zod for runtime type validation +- Use `assertUnreachable(x: never)` for exhaustive type checking +- Add proper JSDoc comments for public APIs +- Run `yarn check-types` regularly during development +- Use `tsx` CLI to execute TypeScript scripts directly \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..1ed453a37 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.{js,json,yml}] +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..40017413f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text eol=lf + +/.yarn/** linguist-vendored +/.yarn/releases/* binary +/.yarn/plugins/**/* binary +/.pnp.* binary linguist-generated + +*.png binary +*.jpg binary + diff --git a/.github/media/clients/javascript.svg b/.github/media/clients/javascript.svg new file mode 100644 index 000000000..6e10c7e9d --- /dev/null +++ b/.github/media/clients/javascript.svg @@ -0,0 +1 @@ +JavaScript diff --git a/.github/media/clients/nextjs.svg b/.github/media/clients/nextjs.svg new file mode 100644 index 000000000..e2da0adf9 --- /dev/null +++ b/.github/media/clients/nextjs.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/.github/media/clients/python.svg b/.github/media/clients/python.svg new file mode 100644 index 000000000..84dd1f953 --- /dev/null +++ b/.github/media/clients/python.svg @@ -0,0 +1,54 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.github/media/clients/react.svg b/.github/media/clients/react.svg new file mode 100644 index 000000000..001b82e80 --- /dev/null +++ b/.github/media/clients/react.svg @@ -0,0 +1 @@ + diff --git a/.github/media/clients/rust.svg b/.github/media/clients/rust.svg new file mode 100644 index 000000000..3e5a5ebfe --- /dev/null +++ b/.github/media/clients/rust.svg @@ -0,0 +1 @@ +Rust diff --git a/.github/media/clients/typescript.svg b/.github/media/clients/typescript.svg new file mode 100644 index 000000000..6259bc9e7 --- /dev/null +++ b/.github/media/clients/typescript.svg @@ -0,0 +1 @@ +TypeScript diff --git a/.github/media/clients/vue.svg b/.github/media/clients/vue.svg new file mode 100644 index 000000000..1c509d9d8 --- /dev/null +++ b/.github/media/clients/vue.svg @@ -0,0 +1 @@ + diff --git a/.github/media/code.png b/.github/media/code.png new file mode 100644 index 000000000..55f3b40df Binary files /dev/null and b/.github/media/code.png differ diff --git a/.github/media/icon-text-white.svg b/.github/media/icon-text-white.svg new file mode 100644 index 000000000..b4acf43f1 --- /dev/null +++ b/.github/media/icon-text-white.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.github/media/icons/bolt-regular.svg b/.github/media/icons/bolt-regular.svg new file mode 100644 index 000000000..4c2dfcf8f --- /dev/null +++ b/.github/media/icons/bolt-regular.svg @@ -0,0 +1 @@ + diff --git a/.github/media/icons/cloud-regular.svg b/.github/media/icons/cloud-regular.svg new file mode 100644 index 000000000..b4ced7e1f --- /dev/null +++ b/.github/media/icons/cloud-regular.svg @@ -0,0 +1 @@ + diff --git a/.github/media/icons/database-regular.svg b/.github/media/icons/database-regular.svg new file mode 100644 index 000000000..24532d94c --- /dev/null +++ b/.github/media/icons/database-regular.svg @@ -0,0 +1 @@ + diff --git a/.github/media/icons/globe-regular.svg b/.github/media/icons/globe-regular.svg new file mode 100644 index 000000000..5ec55387c --- /dev/null +++ b/.github/media/icons/globe-regular.svg @@ -0,0 +1 @@ + diff --git a/.github/media/icons/microchip-regular.svg b/.github/media/icons/microchip-regular.svg new file mode 100644 index 000000000..6588f197e --- /dev/null +++ b/.github/media/icons/microchip-regular.svg @@ -0,0 +1 @@ + diff --git a/.github/media/icons/tower-broadcast-regular.svg b/.github/media/icons/tower-broadcast-regular.svg new file mode 100644 index 000000000..bf5863619 --- /dev/null +++ b/.github/media/icons/tower-broadcast-regular.svg @@ -0,0 +1 @@ + diff --git a/.github/media/integrations/better-auth.svg b/.github/media/integrations/better-auth.svg new file mode 100644 index 000000000..45cb774df --- /dev/null +++ b/.github/media/integrations/better-auth.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/.github/media/integrations/elysia.svg b/.github/media/integrations/elysia.svg new file mode 100644 index 000000000..db27a0ebf --- /dev/null +++ b/.github/media/integrations/elysia.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/.github/media/integrations/express.svg b/.github/media/integrations/express.svg new file mode 100644 index 000000000..8e4296838 --- /dev/null +++ b/.github/media/integrations/express.svg @@ -0,0 +1 @@ + diff --git a/.github/media/integrations/hono.svg b/.github/media/integrations/hono.svg new file mode 100644 index 000000000..200e488e1 --- /dev/null +++ b/.github/media/integrations/hono.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/.github/media/integrations/livestore.svg b/.github/media/integrations/livestore.svg new file mode 100644 index 000000000..2592d779d --- /dev/null +++ b/.github/media/integrations/livestore.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/.github/media/integrations/resend.svg b/.github/media/integrations/resend.svg new file mode 100644 index 000000000..3f1491556 --- /dev/null +++ b/.github/media/integrations/resend.svg @@ -0,0 +1,3 @@ + + + diff --git a/.github/media/integrations/tinybase.svg b/.github/media/integrations/tinybase.svg new file mode 100644 index 000000000..af127bf91 --- /dev/null +++ b/.github/media/integrations/tinybase.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/.github/media/integrations/trpc.svg b/.github/media/integrations/trpc.svg new file mode 100644 index 000000000..103966bc0 --- /dev/null +++ b/.github/media/integrations/trpc.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/.github/media/integrations/vitest.svg b/.github/media/integrations/vitest.svg new file mode 100644 index 000000000..e5b59bb82 --- /dev/null +++ b/.github/media/integrations/vitest.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/media/integrations/yjs.svg b/.github/media/integrations/yjs.svg new file mode 100644 index 000000000..5a1147276 --- /dev/null +++ b/.github/media/integrations/yjs.svg @@ -0,0 +1,24 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.github/media/integrations/zerosync.svg b/.github/media/integrations/zerosync.svg new file mode 100644 index 000000000..0d9727c9d --- /dev/null +++ b/.github/media/integrations/zerosync.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/.github/media/logo/dark.svg b/.github/media/logo/dark.svg new file mode 100644 index 000000000..b4acf43f1 --- /dev/null +++ b/.github/media/logo/dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.github/media/logo/light.svg b/.github/media/logo/light.svg new file mode 100644 index 000000000..0972b49a5 --- /dev/null +++ b/.github/media/logo/light.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.github/media/og-image-github.png b/.github/media/og-image-github.png new file mode 100644 index 000000000..8268e8b28 Binary files /dev/null and b/.github/media/og-image-github.png differ diff --git a/.github/media/og-image.png b/.github/media/og-image.png new file mode 100644 index 000000000..ade95b918 Binary files /dev/null and b/.github/media/og-image.png differ diff --git a/.github/media/platforms/actor-core.svg b/.github/media/platforms/actor-core.svg new file mode 100644 index 000000000..93045c9d5 --- /dev/null +++ b/.github/media/platforms/actor-core.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/media/platforms/aws-lambda.svg b/.github/media/platforms/aws-lambda.svg new file mode 100644 index 000000000..8cd574732 --- /dev/null +++ b/.github/media/platforms/aws-lambda.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + diff --git a/.github/media/platforms/bun.svg b/.github/media/platforms/bun.svg new file mode 100644 index 000000000..edd9f242b --- /dev/null +++ b/.github/media/platforms/bun.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/.github/media/platforms/cloudflare-workers.svg b/.github/media/platforms/cloudflare-workers.svg new file mode 100644 index 000000000..739274a99 --- /dev/null +++ b/.github/media/platforms/cloudflare-workers.svg @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/.github/media/platforms/file-system.svg b/.github/media/platforms/file-system.svg new file mode 100644 index 000000000..ce0539fc8 --- /dev/null +++ b/.github/media/platforms/file-system.svg @@ -0,0 +1 @@ + diff --git a/.github/media/platforms/memory.svg b/.github/media/platforms/memory.svg new file mode 100644 index 000000000..484a48220 --- /dev/null +++ b/.github/media/platforms/memory.svg @@ -0,0 +1 @@ + diff --git a/.github/media/platforms/nodejs.svg b/.github/media/platforms/nodejs.svg new file mode 100644 index 000000000..5f3bd1111 --- /dev/null +++ b/.github/media/platforms/nodejs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.github/media/platforms/postgres.svg b/.github/media/platforms/postgres.svg new file mode 100644 index 000000000..8666f75c1 --- /dev/null +++ b/.github/media/platforms/postgres.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.github/media/platforms/redis.svg b/.github/media/platforms/redis.svg new file mode 100644 index 000000000..ad593a83e --- /dev/null +++ b/.github/media/platforms/redis.svg @@ -0,0 +1,37 @@ + + + + + + + + + + +]> + + + + + + + + + + diff --git a/.github/media/platforms/rivet-bg.svg b/.github/media/platforms/rivet-bg.svg new file mode 100644 index 000000000..701f8f6a6 --- /dev/null +++ b/.github/media/platforms/rivet-bg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/.github/media/platforms/rivet-white.svg b/.github/media/platforms/rivet-white.svg new file mode 100644 index 000000000..81337354d --- /dev/null +++ b/.github/media/platforms/rivet-white.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/.github/media/platforms/socketio.svg b/.github/media/platforms/socketio.svg new file mode 100644 index 000000000..2818efeb0 --- /dev/null +++ b/.github/media/platforms/socketio.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.github/media/platforms/supabase.svg b/.github/media/platforms/supabase.svg new file mode 100644 index 000000000..ad802ac16 --- /dev/null +++ b/.github/media/platforms/supabase.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/.github/media/platforms/vercel.svg b/.github/media/platforms/vercel.svg new file mode 100644 index 000000000..4e3b0e933 --- /dev/null +++ b/.github/media/platforms/vercel.svg @@ -0,0 +1,3 @@ + + + diff --git a/.github/media/quotes/posts/1902835527977439591.jpg b/.github/media/quotes/posts/1902835527977439591.jpg new file mode 100644 index 000000000..e4403665a Binary files /dev/null and b/.github/media/quotes/posts/1902835527977439591.jpg differ diff --git a/.github/media/quotes/posts/1909278348812952007.png b/.github/media/quotes/posts/1909278348812952007.png new file mode 100644 index 000000000..f799ec742 Binary files /dev/null and b/.github/media/quotes/posts/1909278348812952007.png differ diff --git a/.github/media/quotes/users/Chinoman10_.jpg b/.github/media/quotes/users/Chinoman10_.jpg new file mode 100644 index 000000000..f37c750a2 Binary files /dev/null and b/.github/media/quotes/users/Chinoman10_.jpg differ diff --git a/.github/media/quotes/users/Social_Quotient.jpg b/.github/media/quotes/users/Social_Quotient.jpg new file mode 100644 index 000000000..ea6fcddbd Binary files /dev/null and b/.github/media/quotes/users/Social_Quotient.jpg differ diff --git a/.github/media/quotes/users/alistaiir.jpg b/.github/media/quotes/users/alistaiir.jpg new file mode 100644 index 000000000..67f2eaedc Binary files /dev/null and b/.github/media/quotes/users/alistaiir.jpg differ diff --git a/.github/media/quotes/users/devgerred.jpg b/.github/media/quotes/users/devgerred.jpg new file mode 100644 index 000000000..4a5a86e49 Binary files /dev/null and b/.github/media/quotes/users/devgerred.jpg differ diff --git a/.github/media/quotes/users/j0g1t.jpg b/.github/media/quotes/users/j0g1t.jpg new file mode 100644 index 000000000..fb881848e Binary files /dev/null and b/.github/media/quotes/users/j0g1t.jpg differ diff --git a/.github/media/quotes/users/localfirstnews.jpg b/.github/media/quotes/users/localfirstnews.jpg new file mode 100644 index 000000000..55bb2081b Binary files /dev/null and b/.github/media/quotes/users/localfirstnews.jpg differ diff --git a/.github/media/quotes/users/samgoodwin89.jpg b/.github/media/quotes/users/samgoodwin89.jpg new file mode 100644 index 000000000..7d3dbf7a1 Binary files /dev/null and b/.github/media/quotes/users/samgoodwin89.jpg differ diff --git a/.github/media/quotes/users/samk0_com.jpg b/.github/media/quotes/users/samk0_com.jpg new file mode 100644 index 000000000..4ae81a0bb Binary files /dev/null and b/.github/media/quotes/users/samk0_com.jpg differ diff --git a/.github/media/quotes/users/uripont_.jpg b/.github/media/quotes/users/uripont_.jpg new file mode 100644 index 000000000..8cd28fc03 Binary files /dev/null and b/.github/media/quotes/users/uripont_.jpg differ diff --git a/.github/media/screenshots/studio/simple.png b/.github/media/screenshots/studio/simple.png new file mode 100644 index 000000000..1b9fa422c Binary files /dev/null and b/.github/media/screenshots/studio/simple.png differ diff --git a/.github/media/studio-video-demo5.png b/.github/media/studio-video-demo5.png new file mode 100644 index 000000000..56ad44d93 Binary files /dev/null and b/.github/media/studio-video-demo5.png differ diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000..ecd27d0a5 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,75 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Direct prompt for automated review (no @claude mention needed) + direct_prompt: | + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Be constructive and helpful in your feedback. + + # Optional: Customize review based on file types + # direct_prompt: | + # Review this PR focusing on: + # - For TypeScript files: Type safety and proper interface usage + # - For API endpoints: Security, input validation, and error handling + # - For React components: Performance, accessibility, and best practices + # - For tests: Coverage, edge cases, and test quality + + # Optional: Different prompts for different authors + # direct_prompt: | + # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && + # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || + # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} + + # Optional: Add specific tools for running tests or linting + # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" + + # Optional: Skip review for certain conditions + # if: | + # !contains(github.event.pull_request.title, '[skip-review]') && + # !contains(github.event.pull_request.title, '[WIP]') + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..a083813ae --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,64 @@ +name: Claude Code + +permissions: + contents: write + issues: write + pull-requests: write + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" + + # Optional: Trigger when specific user is assigned to an issue + # assignee_trigger: "claude-bot" + + # Optional: Allow Claude to run specific commands + # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" + + # Optional: Add custom instructions for Claude to customize its behavior for your project + # custom_instructions: | + # Follow our coding standards + # Ensure all new code has tests + # Use TypeScript for new files + + # Optional: Custom environment variables for Claude + # claude_env: | + # NODE_ENV: test + diff --git a/.github/workflows/pkg-pr-new.yaml b/.github/workflows/pkg-pr-new.yaml new file mode 100644 index 000000000..ade975ff1 --- /dev/null +++ b/.github/workflows/pkg-pr-new.yaml @@ -0,0 +1,13 @@ +on: + pull_request: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + - run: pnpm install + - run: pnpm build + - run: pnpm dlx pkg-pr-new publish 'packages/*' 'packages/**/platforms/*' 'packages/frameworks/*' 'packages/drivers/*' --packageManager pnpm diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..a99f13508 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,100 @@ +# TODO: Cache both yarn & cargo + +name: 'Test' +on: + pull_request: + # TODO: Graphite does not support path filters + # paths: + # - 'packages/**' + # - 'clients/**' + # - 'examples/**' + # - '.github/workflows/**' + # - 'package.json' + # - 'yarn.lock' + # - 'tsconfig*.json' + # - 'turbo.json' + # - 'tsup.base.ts' + # - 'biome.json' + # - 'Cargo.toml' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # Setup Node.js + - name: Set up Node.js + uses: actions/setup-node@v4 + 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 + with: + path: | + ${{ steps.yarn-cache-dir-path.outputs.dir }} + .turbo + key: ${{ runner.os }}-deps-${{ hashFiles('**/yarn.lock') }}-${{ github.sha }} + 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: Install Rust + # uses: dtolnay/rust-toolchain@stable + # - name: Run Rust client tests + # run: cd rust/client && cargo test + + # TODO: This is broken + # test-cli: + # runs-on: ubuntu-latest + # + # services: + # verdaccio: + # image: verdaccio/verdaccio:6 + # ports: + # - 4873:4873 + # options: --name verdaccio + # + # steps: + # - uses: actions/checkout@v4 + # - run: corepack enable + # # https://github.com/orgs/community/discussions/42127 + # - run: /usr/bin/docker cp ${{ github.workspace }}/.verdaccio/conf/config.yaml verdaccio:/verdaccio/conf/config.yaml + # - run: /usr/bin/docker restart verdaccio + # + # - uses: actions/cache@v4 + # with: + # path: .turbo + # key: ${{ runner.os }}-turbo-${{ github.sha }} + # restore-keys: | + # ${{ runner.os }}-turbo- + # - uses: actions/setup-node@v4 + # with: + # node-version: '22.14' + # cache: 'yarn' + # - run: yarn install + # - run: yarn build + # - run: npm i -g tsx + # - run: ./scripts/e2e-publish.ts + # - run: yarn workspace @actor-core/cli run test + # diff --git a/.gitignore b/.gitignore index 68babccba..bfb1e5e86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,186 @@ -# Turborepo -**/.turbo +# Created by https://www.toptal.com/developers/gitignore/api/node,yarn,turbo,rust +# Edit at https://www.toptal.com/developers/gitignore?templates=node,yarn,turbo,rust -# Node.js and pnpm -node_modules/ +### Node ### +# Logs +logs +*.log npm-debug.log* -pnpm-debug.log* -.pnpm-store/ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ -# TypeScript +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache *.tsbuildinfo -dist/ -# IDE -.vscode/ -.idea/ -*.swp -*.swo +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +### Turbo ### +# Turborepo task cache +.turbo + +### yarn ### +# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored + +.yarn/* +!.yarn/releases +!.yarn/patches +!.yarn/plugins +!.yarn/sdks +!.yarn/versions + +# if you are NOT using Zero-installs, then: +# comment the following lines +!.yarn/cache + +# and uncomment the following lines +# .pnp.* -# OS -.DS_Store -Thumbs.db +# End of https://www.toptal.com/developers/gitignore/api/node,yarn,turbo,rust -# Secrets -secrets/**/* +.yarn/ +**/.wrangler +**/.DS_Store +.aider* diff --git a/.verdaccio/.gitignore b/.verdaccio/.gitignore new file mode 100644 index 000000000..457fc4787 --- /dev/null +++ b/.verdaccio/.gitignore @@ -0,0 +1 @@ +./conf/storage \ No newline at end of file diff --git a/.verdaccio/conf/config.yaml b/.verdaccio/conf/config.yaml new file mode 100644 index 000000000..f325327bf --- /dev/null +++ b/.verdaccio/conf/config.yaml @@ -0,0 +1,158 @@ +# path to a directory with all packages +storage: /verdaccio/storage/data +# path to a directory with plugins to include +plugins: /verdaccio/plugins + +# https://verdaccio.org/docs/configuration#authentication +auth: + htpasswd: + file: /verdaccio/storage/htpasswd + # Maximum amount of users allowed to register, defaults to "+infinity". + # You can set this to -1 to disable registration. + # max_users: 1000 + # Hash algorithm, possible options are: "bcrypt", "md5", "sha1", "crypt". + # algorithm: bcrypt # by default is crypt, but is recommended use bcrypt for new installations + # Rounds number for "bcrypt", will be ignored for other algorithms. + # rounds: 10 + +# https://verdaccio.org/docs/configuration#uplinks +# a list of other known repositories we can talk to +uplinks: + npmjs: + url: https://registry.npmjs.org/ + +# Learn how to protect your packages +# https://verdaccio.org/docs/protect-your-dependencies/ +# https://verdaccio.org/docs/configuration#packages +packages: + '@actor-core/*': + access: $all + publish: $authenticated + unpublish: $authenticated + + "actor-core": + access: $all + publish: $authenticated + unpublish: $authenticated + + "create-actor": + access: $all + publish: $authenticated + unpublish: $authenticated + + '**': + # allow all users (including non-authenticated users) to read and + # publish all packages + # + # you can specify usernames/groupnames (depending on your auth plugin) + # and three keywords: "$all", "$anonymous", "$authenticated" + access: $all + # if package is not available locally, proxy requests to 'npmjs' registry + proxy: npmjs + +# To improve your security configuration and avoid dependency confusion +# consider removing the proxy property for private packages +# https://verdaccio.org/docs/best#remove-proxy-to-increase-security-at-private-packages + +# https://verdaccio.org/docs/configuration#server +# You can specify HTTP/1.1 server keep alive timeout in seconds for incoming connections. +# A value of 0 makes the http server behave similarly to Node.js versions prior to 8.0.0, which did not have a keep-alive timeout. +# WORKAROUND: Through given configuration you can workaround following issue https://github.com/verdaccio/verdaccio/issues/301. Set to 0 in case 60 is not enough. +server: + keepAliveTimeout: 60 + # Allow `req.ip` to resolve properly when Verdaccio is behind a proxy or load-balancer + # See: https://expressjs.com/en/guide/behind-proxies.html + # trustProxy: '127.0.0.1' + +# https://verdaccio.org/docs/configuration#offline-publish +# publish: +# allow_offline: false + +# https://verdaccio.org/docs/configuration#url-prefix +# url_prefix: /verdaccio/ +# VERDACCIO_PUBLIC_URL='https://somedomain.org'; +# url_prefix: '/my_prefix' +# // url -> https://somedomain.org/my_prefix/ +# VERDACCIO_PUBLIC_URL='https://somedomain.org'; +# url_prefix: '/' +# // url -> https://somedomain.org/ +# VERDACCIO_PUBLIC_URL='https://somedomain.org/first_prefix'; +# url_prefix: '/second_prefix' +# // url -> https://somedomain.org/second_prefix/' + +# https://verdaccio.org/docs/configuration#security +# security: +# api: +# legacy: true +# # recomended set to true for older installations +# migrateToSecureLegacySignature: true +# jwt: +# sign: +# expiresIn: 29d +# verify: +# someProp: [value] +# web: +# sign: +# expiresIn: 1h # 1 hour by default +# verify: +# someProp: [value] + +# https://verdaccio.org/docs/configuration#user-rate-limit +# userRateLimit: +# windowMs: 50000 +# max: 1000 + +# https://verdaccio.org/docs/configuration#max-body-size +# max_body_size: 10mb + +# https://verdaccio.org/docs/configuration#listen-port +# listen: +# - localhost:4873 # default value +# - http://localhost:4873 # same thing +# - 0.0.0.0:4873 # listen on all addresses (INADDR_ANY) +# - https://example.org:4873 # if you want to use https +# - "[::1]:4873" # ipv6 +# - unix:/tmp/verdaccio.sock # unix socket + +# The HTTPS configuration is useful if you do not consider use a HTTP Proxy +# https://verdaccio.org/docs/configuration#https +# https: +# key: ./path/verdaccio-key.pem +# cert: ./path/verdaccio-cert.pem +# ca: ./path/verdaccio-csr.pem + +# https://verdaccio.org/docs/configuration#proxy +# http_proxy: http://something.local/ +# https_proxy: https://something.local/ + +# https://verdaccio.org/docs/configuration#notifications +# notify: +# method: POST +# headers: [{ "Content-Type": "application/json" }] +# endpoint: https://usagge.hipchat.com/v2/room/3729485/notification?auth_token=mySecretToken +# content: '{"color":"green","message":"New package published: * {{ name }}*","notify":true,"message_format":"text"}' + +middlewares: + audit: + enabled: true + +# https://verdaccio.org/docs/logger +# log settings +log: { type: stdout, format: pretty, level: trace } +#experiments: +# # support for npm token command +# token: false +# # disable writing body size to logs, read more on ticket 1912 +# bytesin_off: false +# # enable tarball URL redirect for hosting tarball with a different server, the tarball_url_redirect can be a template string +# tarball_url_redirect: 'https://mycdn.com/verdaccio/${packageName}/${filename}' +# # the tarball_url_redirect can be a function, takes packageName and filename and returns the url, when working with a js configuration file +# tarball_url_redirect(packageName, filename) { +# const signedUrl = // generate a signed url +# return signedUrl; +# } + +# translate your registry, api i18n not available yet +# i18n: +# list of the available translations https://github.com/verdaccio/verdaccio/blob/master/packages/plugins/ui-theme/src/i18n/ABOUT_TRANSLATIONS.md +# web: en-US diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..d11248bb0 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,30 @@ +## Routing + +## P2P + +## Actor Loading + +- Manager.create to create actor +- ActorDriver.loadActor to load actor in to memory + - ActorDefinition.instantiate to create `ActorInstance` class + - ActorInstance.start to start it + +## Actor Lifecycle & Sleeping + +- FS + - Actors do not go to sleep +- DO + - Up to Cloudflare +- Redis + - P2P -- goes to sleep when no requests are currently using the actor + +## Main symbols + +- RunConfig +- Registry & RegistryConfig +- Client & inline client +- ManagerDriver +- ActorDriver +- GenericConnGlobalState & other generic drivers: tracks actual connections separately from the actual conn state + - TODO: Can we remove the "generic" prefix? + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..365683e84 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,439 @@ +# Changelog + +## [0.7.6](https://github.com/rivet-gg/rivetkit/compare/v0.7.5...v0.7.6) (2025-03-26) + + +### Features + +* add `rivetkit/test` ([#790](https://github.com/rivet-gg/rivetkit/issues/790)) ([bf5e675](https://github.com/rivet-gg/rivetkit/commit/bf5e6754982a286fd41bdbb7c86d45d0d55df47f)) + + +### Bug Fixes + +* fix combining `CreateState` and `CreateVars` causing `V` to be `unknown` ([#794](https://github.com/rivet-gg/rivetkit/issues/794)) ([61bc9ad](https://github.com/rivet-gg/rivetkit/commit/61bc9ad07e1acdf950adf57346115169a2075209)) + + +### Documentation + +* 0.7.5 changelog ([12db6e4](https://github.com/rivet-gg/rivetkit/commit/12db6e4204d64dfa933cbfd0714655ef0c74cdaa)) +* document createVars & driver-specific values ([#787](https://github.com/rivet-gg/rivetkit/issues/787)) ([14c2829](https://github.com/rivet-gg/rivetkit/commit/14c282916ec96022c96d2da9eef83340f3d67360)) +* fix localhost link ([991fc99](https://github.com/rivet-gg/rivetkit/commit/991fc99d584d6fa12d175d5017f4a094de9e9eb0)) + + +### Continuous Integration + +* add turborepo cache ([#792](https://github.com/rivet-gg/rivetkit/issues/792)) ([169260a](https://github.com/rivet-gg/rivetkit/commit/169260a92ff471dad8927d9c3d782869acdbb35d)) +* add yarn cache ([#791](https://github.com/rivet-gg/rivetkit/issues/791)) ([d65d1fa](https://github.com/rivet-gg/rivetkit/commit/d65d1fa49171508bfbc19b7dead785bc6e982419)) + + +### Chores + +* release 0.7.6 ([d996b39](https://github.com/rivet-gg/rivetkit/commit/d996b39f1152b70a994c524bb8bc17c878de57c3)) +* release version 0.7.6 ([3c2c0d1](https://github.com/rivet-gg/rivetkit/commit/3c2c0d1a9a30b206ca48e179b26a943db0a5fa3a)) + +## [0.7.5](https://github.com/rivet-gg/rivetkit/compare/v0.7.3...v0.7.5) (2025-03-25) + + +### Features + +* add ability to access driver context ([#781](https://github.com/rivet-gg/rivetkit/issues/781)) ([c86720a](https://github.com/rivet-gg/rivetkit/commit/c86720a952173bb53c3fb9cf21c57a978ccacbb0)) +* bring actor client up to spec ([#752](https://github.com/rivet-gg/rivetkit/issues/752)) ([d2a5d7e](https://github.com/rivet-gg/rivetkit/commit/d2a5d7e16d11ac2e5a47d0a92eafc28b71001d03)) +* dynamic driver format ([#672](https://github.com/rivet-gg/rivetkit/issues/672)) ([09e6fe1](https://github.com/rivet-gg/rivetkit/commit/09e6fe145a221518981a0ab68b5a7f5cd49bca98)) +* react integration ([#674](https://github.com/rivet-gg/rivetkit/issues/674)) ([de66bf9](https://github.com/rivet-gg/rivetkit/commit/de66bf9ad909487210b4dc2c80edf1ab13f1e015)) +* **rivet:** add "framework" tag ([#754](https://github.com/rivet-gg/rivetkit/issues/754)) ([412d4cc](https://github.com/rivet-gg/rivetkit/commit/412d4cccf7d30d910b7a1345067fbaf5b89d10bc)) +* **rust:** setup rs actor handle basics ([#639](https://github.com/rivet-gg/rivetkit/issues/639)) ([79f9429](https://github.com/rivet-gg/rivetkit/commit/79f94290266fcbb0e6ec36c5428ee290cefe63b5)) + + +### Bug Fixes + +* **cli:** add `tsx` package to CLI examples ([#772](https://github.com/rivet-gg/rivetkit/issues/772)) ([ffd46df](https://github.com/rivet-gg/rivetkit/commit/ffd46dfb62f008e4da986dabda47062fbaa73fa8)) +* **cli:** bump minimal rivet version ([#777](https://github.com/rivet-gg/rivetkit/issues/777)) ([7a4ca74](https://github.com/rivet-gg/rivetkit/commit/7a4ca7417813070ee654da1ff140cf83b4ba31ff)) +* **cli:** fix pnpm create command ([#778](https://github.com/rivet-gg/rivetkit/issues/778)) ([ea21e77](https://github.com/rivet-gg/rivetkit/commit/ea21e77561a0bd8378667c8ae46c02d24d0e2dfb)) +* **cli:** fix yarn create ([#776](https://github.com/rivet-gg/rivetkit/issues/776)) ([f826681](https://github.com/rivet-gg/rivetkit/commit/f826681455fce77f7b2ab8626000e1e818deee6a)) + + +### Documentation + +* add changelog ([#763](https://github.com/rivet-gg/rivetkit/issues/763)) ([df647b1](https://github.com/rivet-gg/rivetkit/commit/df647b1f1b2d82e40bcc204048cf6901b8e039b6)) +* change Cloudflare Workers -> Cloudflare Durable Objects ([#766](https://github.com/rivet-gg/rivetkit/issues/766)) ([78b244d](https://github.com/rivet-gg/rivetkit/commit/78b244d8751ec4db4f747db1652974f4db122c61)) +* clean up changelog formatting ([#764](https://github.com/rivet-gg/rivetkit/issues/764)) ([22fc429](https://github.com/rivet-gg/rivetkit/commit/22fc429ddc58e91655a3a4dc73ffc60338132911)) +* rename Cloudflare Durable Objects -> Durable Objects ([#770](https://github.com/rivet-gg/rivetkit/issues/770)) ([a9a5cfa](https://github.com/rivet-gg/rivetkit/commit/a9a5cfa5b91aff73889bbbee965d52221f89c2c7)) +* rename platform page Cloudflare Workers -> Cloudflare Durable Objects ([#768](https://github.com/rivet-gg/rivetkit/issues/768)) ([1a7b629](https://github.com/rivet-gg/rivetkit/commit/1a7b629e7d782443eacfa39fe6f3a055ae306704)) + + +### Code Refactoring + +* **cli:** remove rivetkit.config file ([#775](https://github.com/rivet-gg/rivetkit/issues/775)) ([8c1bb2f](https://github.com/rivet-gg/rivetkit/commit/8c1bb2fa543fd0722ed1a559b29fccf3c4570c95)) +* **react:** update types ([#751](https://github.com/rivet-gg/rivetkit/issues/751)) ([822818d](https://github.com/rivet-gg/rivetkit/commit/822818dbd673015b976f99c53ec6a3ef62935fc2)) + + +### Continuous Integration + +* add rust test ([#758](https://github.com/rivet-gg/rivetkit/issues/758)) ([f3f73fd](https://github.com/rivet-gg/rivetkit/commit/f3f73fdb50429429591eb430bdda2558e743ce2a)) + + +### Chores + +* add rivetkit test ([#755](https://github.com/rivet-gg/rivetkit/issues/755)) ([362d713](https://github.com/rivet-gg/rivetkit/commit/362d7137b35d9436342d8e445e1c6af8e28a8749)) +* add docs on rust client ([#757](https://github.com/rivet-gg/rivetkit/issues/757)) ([2ffeb95](https://github.com/rivet-gg/rivetkit/commit/2ffeb952cf89282175c999e081c2dd9e0e5c7bf3)) +* add e2e test for rust client ([#756](https://github.com/rivet-gg/rivetkit/issues/756)) ([4057e1b](https://github.com/rivet-gg/rivetkit/commit/4057e1b0f1bd05b9b361068fbf7b35f3698b0cb9)) +* add tracing logs ([#753](https://github.com/rivet-gg/rivetkit/issues/753)) ([ec2febd](https://github.com/rivet-gg/rivetkit/commit/ec2febd2cceecd5067539c45deb5c2b312179414)) +* disable cursor example ([#785](https://github.com/rivet-gg/rivetkit/issues/785)) ([8cf2a70](https://github.com/rivet-gg/rivetkit/commit/8cf2a70665427ecb4c6ca27f2267e57c2fc60631)) +* move rust/client/ -> clients/rust/ ([#759](https://github.com/rivet-gg/rivetkit/issues/759)) ([5eabc17](https://github.com/rivet-gg/rivetkit/commit/5eabc179bbf32b97487d58c32a70bc4640fa1215)) +* release 0.7.4 ([5186163](https://github.com/rivet-gg/rivetkit/commit/518616399219935ad5fb6a9c8ea7aebf92e63b7b)) +* release 0.7.4 ([5c9cba8](https://github.com/rivet-gg/rivetkit/commit/5c9cba8da288bf8beab02316319dc32913521bd0)) +* release 0.7.4 ([1168fbb](https://github.com/rivet-gg/rivetkit/commit/1168fbb4dfd19d10cd891f89c110eb5f9eb6b348)) +* release 0.7.5 ([8e39e76](https://github.com/rivet-gg/rivetkit/commit/8e39e766e3f5c733c82e319e79f321891ce66350)) +* release version 0.7.4 ([014b4ca](https://github.com/rivet-gg/rivetkit/commit/014b4caf1af3d49cea17dc7634aa79dfeb998d61)) +* release version 0.7.5 ([08dfe6f](https://github.com/rivet-gg/rivetkit/commit/08dfe6f33a133e49a4356f2fe59b8a8857d54a5e)) +* update publish script ([#760](https://github.com/rivet-gg/rivetkit/issues/760)) ([6c5165e](https://github.com/rivet-gg/rivetkit/commit/6c5165e40d0d47e0b347de2d8c5b4e0ff57c627a)) + +## [0.7.3](https://github.com/rivet-gg/rivetkit/compare/v0.7.2...v0.7.3) (2025-03-18) + + +### Bug Fixes + +* **rivetkit:** make invariant a required dependency ([#747](https://github.com/rivet-gg/rivetkit/issues/747)) ([ed893c0](https://github.com/rivet-gg/rivetkit/commit/ed893c0eefd94c7758659bf16b98d8dded3f2c9a)) +* **cli:** update rivet to use new schema ([#748](https://github.com/rivet-gg/rivetkit/issues/748)) ([8bd2fb6](https://github.com/rivet-gg/rivetkit/commit/8bd2fb63125a00894738454e19e2c0bd576f59ef)) +* **cloudflare-workers:** fix accepting empty schema ([#749](https://github.com/rivet-gg/rivetkit/issues/749)) ([c05e97d](https://github.com/rivet-gg/rivetkit/commit/c05e97d88d2ece987578b8fbb718268324326c2c)) + + +### Documentation + +* document context types and ephemeral `c.vars` ([#743](https://github.com/rivet-gg/rivetkit/issues/743)) ([88ee0a8](https://github.com/rivet-gg/rivetkit/commit/88ee0a8e3497ce71da43c86c798e4958202ad48d)) + + +### Chores + +* release 0.7.3 ([c275c38](https://github.com/rivet-gg/rivetkit/commit/c275c381793f6b38e1f92c335fa95a1b11e676e2)) +* release version 0.7.3 ([f269653](https://github.com/rivet-gg/rivetkit/commit/f269653b489c7acb993fb9f6c1fbec4d241f675e)) +* remove templates from workspace ([4770c79](https://github.com/rivet-gg/rivetkit/commit/4770c791a90cac225fbefb1b983bd1d2f952355e)) + +## [0.7.2](https://github.com/rivet-gg/rivetkit/compare/v0.7.1...v0.7.2) (2025-03-18) + + +### ⚠ BREAKING CHANGES + +* rename all uses of "connection" -> "conn" and "parameter" -> "param" ([#733](https://github.com/rivet-gg/rivetkit/issues/733)) + +### Features + +* add `ActorContextOf` to get the context of an actor definition ([#734](https://github.com/rivet-gg/rivetkit/issues/734)) ([d64c05d](https://github.com/rivet-gg/rivetkit/commit/d64c05df12b10a0d94c341b62719bb091fc75225)) +* add `vars` for storing ephemeral data ([#738](https://github.com/rivet-gg/rivetkit/issues/738)) ([a93fe86](https://github.com/rivet-gg/rivetkit/commit/a93fe8646097b861b7245ab055d986463639b7b9)) +* expose `ActionContextOf` ([#740](https://github.com/rivet-gg/rivetkit/issues/740)) ([97c161c](https://github.com/rivet-gg/rivetkit/commit/97c161c21b47f5f336c88eb5d75b506d300c2d1d)) + + +### Chores + +* add rivetkit type tests ([#737](https://github.com/rivet-gg/rivetkit/issues/737)) ([88e5dca](https://github.com/rivet-gg/rivetkit/commit/88e5dca697c99dc245426fca66f9979c6a16d0e2)) +* release 0.7.2 ([265f2e2](https://github.com/rivet-gg/rivetkit/commit/265f2e20dc7a33130b69968a8409562f6dffe17e)) +* release version 0.7.2 ([aae7497](https://github.com/rivet-gg/rivetkit/commit/aae7497713dab75c90344f2949764873b49b1c47)) +* rename all uses of "connection" -> "conn" and "parameter" -> "param" ([#733](https://github.com/rivet-gg/rivetkit/issues/733)) ([2095fdf](https://github.com/rivet-gg/rivetkit/commit/2095fdfae0bfabf4ebbe35f69404e0a29210d1ed)) + +## [0.7.1](https://github.com/rivet-gg/rivetkit/compare/v0.7.0...v0.7.1) (2025-03-17) + + +### ⚠ BREAKING CHANGES + +* rename onInitialize -> onCreate ([#714](https://github.com/rivet-gg/rivetkit/issues/714)) +* rename rpcs -> actions ([#711](https://github.com/rivet-gg/rivetkit/issues/711)) +* expose functional interface for actors ([#710](https://github.com/rivet-gg/rivetkit/issues/710)) + +### Features + +* add client dispose method to clean up actor handles ([#686](https://github.com/rivet-gg/rivetkit/issues/686)) ([ff1e64d](https://github.com/rivet-gg/rivetkit/commit/ff1e64d952798f86cc4d67505a7fa2904749217b)) +* **cli:** add `--skip-manager` flag on deploy ([#708](https://github.com/rivet-gg/rivetkit/issues/708)) ([f46776d](https://github.com/rivet-gg/rivetkit/commit/f46776d21f4c669d8f1d134743889d3591f12a5d)) +* expose `name` in context ([#723](https://github.com/rivet-gg/rivetkit/issues/723)) ([0fab6ec](https://github.com/rivet-gg/rivetkit/commit/0fab6ec019a5a6befbe9824833791c492a87e2f7)) +* expose functional interface for actors ([#710](https://github.com/rivet-gg/rivetkit/issues/710)) ([803133d](https://github.com/rivet-gg/rivetkit/commit/803133d9f7404db5479bf92635eafc1c9f99acef)) + + +### Bug Fixes + +* **client:** fix fallback priority of websockets & eventsources ([#700](https://github.com/rivet-gg/rivetkit/issues/700)) ([86550a0](https://github.com/rivet-gg/rivetkit/commit/86550a0ca5838ab4cd0c5f3d4229f3031d037d10)) +* **client:** modify endpoint to start with `ws` and `wss` ([#690](https://github.com/rivet-gg/rivetkit/issues/690)) ([8aed4ce](https://github.com/rivet-gg/rivetkit/commit/8aed4ceba6724d85c091a7660e5addcd7308c5cd)) +* **cli:** escape combined command, allow npx to install pkg ([#695](https://github.com/rivet-gg/rivetkit/issues/695)) ([0f173e6](https://github.com/rivet-gg/rivetkit/commit/0f173e68c074236fd59437574b9c5f499db8d55d)) +* **cli:** force to use npx when calling @rivet-gg/cli ([#698](https://github.com/rivet-gg/rivetkit/issues/698)) ([7d3d1d9](https://github.com/rivet-gg/rivetkit/commit/7d3d1d99127d0373d29c33dedd16d3aeadf9e318)) +* correct "onwer" typo to "owner" in deploy command ([#694](https://github.com/rivet-gg/rivetkit/issues/694)) ([cbc1255](https://github.com/rivet-gg/rivetkit/commit/cbc1255ae73ce9be07bfc80e97dd61f868579769)) +* fix schedule logging schedule errors ([#709](https://github.com/rivet-gg/rivetkit/issues/709)) ([f336561](https://github.com/rivet-gg/rivetkit/commit/f336561e7427eb87ed4ee930d405cc571a2cd775)) +* implement schedule event saving functionality ([#687](https://github.com/rivet-gg/rivetkit/issues/687)) ([59f78f3](https://github.com/rivet-gg/rivetkit/commit/59f78f39a6cfd5d050d5359bbc224a6d7a2a3ea8)) +* make `UserErrorOptions.metadata` optional ([#724](https://github.com/rivet-gg/rivetkit/issues/724)) ([32037c6](https://github.com/rivet-gg/rivetkit/commit/32037c6493fa17b68f2d84bc5e1e57dc411e508a)) +* remove use of .disconnect in example ([382ddb8](https://github.com/rivet-gg/rivetkit/commit/382ddb84cb14f6d22edf55281da4b4c030bfeb44)) +* skip CORS for WebSocket routes ([#703](https://github.com/rivet-gg/rivetkit/issues/703)) ([d51d618](https://github.com/rivet-gg/rivetkit/commit/d51d618c7f40daeead28716194534ab944293fbd)) +* use app.notFound instead of app.all("*") for 404 handling ([#701](https://github.com/rivet-gg/rivetkit/issues/701)) ([727dd28](https://github.com/rivet-gg/rivetkit/commit/727dd280c84e0d09928f62d4b99531d58900f865)) + + +### Documentation + +* update docs for new changes ([#713](https://github.com/rivet-gg/rivetkit/issues/713)) ([fa990dd](https://github.com/rivet-gg/rivetkit/commit/fa990dd22fdfc7cefea8f140cbcd2fcf05025dea)) + + +### Chores + +* add explicit error handling for all hono routes ([#702](https://github.com/rivet-gg/rivetkit/issues/702)) ([365de24](https://github.com/rivet-gg/rivetkit/commit/365de24b75061eee931f473414c221286c6e0684)) +* add ws & eventsource as dev dependencies of rivetkit so it can build ([1cdf9c4](https://github.com/rivet-gg/rivetkit/commit/1cdf9c4351367a152224697029b047e5ef66518a)) +* changelog for 0.6.3 ([cf6d723](https://github.com/rivet-gg/rivetkit/commit/cf6d723a081029e8241a643186d41a09701192bd)) +* fix grammar on index ([#689](https://github.com/rivet-gg/rivetkit/issues/689)) ([dac5660](https://github.com/rivet-gg/rivetkit/commit/dac566058490c28ad34511dcee77c962602c6a3e)) +* fix typo of "Actor Core" -> "RivetKit" ([#707](https://github.com/rivet-gg/rivetkit/issues/707)) ([d1e8be5](https://github.com/rivet-gg/rivetkit/commit/d1e8be523fc75e1c55ad529bd85bc832a545b12a)) +* increase RPC timeout from 5s to 60s ([#705](https://github.com/rivet-gg/rivetkit/issues/705)) ([ec6a478](https://github.com/rivet-gg/rivetkit/commit/ec6a478e9ffff91028e8f2f718c79e65d3479354)) +* **main:** release 0.6.2 ([#693](https://github.com/rivet-gg/rivetkit/issues/693)) ([73c3399](https://github.com/rivet-gg/rivetkit/commit/73c3399a96a720ff5663ee359e686aa6e573737b)) +* **main:** release 0.6.3 ([#697](https://github.com/rivet-gg/rivetkit/issues/697)) ([40fbcc1](https://github.com/rivet-gg/rivetkit/commit/40fbcc11d115761f27a843f6cac816449fc61ceb)) +* **main:** release 0.7.0 ([#717](https://github.com/rivet-gg/rivetkit/issues/717)) ([675d13c](https://github.com/rivet-gg/rivetkit/commit/675d13c2852a3c0b811fef51ac9dd4b8b47cd6be)) +* make order of generic params consistent in `ActorConfig` ([#725](https://github.com/rivet-gg/rivetkit/issues/725)) ([6ea34e5](https://github.com/rivet-gg/rivetkit/commit/6ea34e517505113abb40fa7677900bdebe99163e)) +* manually define generic actor config types with hybrid zod validation ([#729](https://github.com/rivet-gg/rivetkit/issues/729)) ([a72eab8](https://github.com/rivet-gg/rivetkit/commit/a72eab8b372faeccf8884265a2fc1b276584a9f1)) +* **memory:** explicitly pass global state to memory driver ([#688](https://github.com/rivet-gg/rivetkit/issues/688)) ([542bd1c](https://github.com/rivet-gg/rivetkit/commit/542bd1c22b5d8844410bd9d3ae970162a6b481f2)) +* move auth to root level of sidebar ([#720](https://github.com/rivet-gg/rivetkit/issues/720)) ([0b8beb7](https://github.com/rivet-gg/rivetkit/commit/0b8beb7f120a12f36a6524d015abbbfc080c6c33)) +* pass `ActorContext` to all `on*` events ([#727](https://github.com/rivet-gg/rivetkit/issues/727)) ([586fb11](https://github.com/rivet-gg/rivetkit/commit/586fb110b6ad4b2f51e6600c350c17587217e062)) +* release 0.6.2 ([4361f9e](https://github.com/rivet-gg/rivetkit/commit/4361f9ea3bbd1da97f51b39772f4d9cc410cb86c)) +* release 0.6.3 ([e06db47](https://github.com/rivet-gg/rivetkit/commit/e06db47aba656e47a721376e767dece5b0cd2934)) +* release 0.7.0 ([0a9b745](https://github.com/rivet-gg/rivetkit/commit/0a9b745f966379ed324be2a354d91999cb65e1f1)) +* release 0.7.1 ([3fe4c3a](https://github.com/rivet-gg/rivetkit/commit/3fe4c3a33fb5ed4b5d3509597ec38a97509ad897)) +* release version 0.6.2 ([677bda2](https://github.com/rivet-gg/rivetkit/commit/677bda2f934ca2a26a1579aeefa871145ecaaecb)) +* release version 0.7.0 ([0fbc3da](https://github.com/rivet-gg/rivetkit/commit/0fbc3da0430581cc47543d2904c8241fa38d4f0e)) +* rename onInitialize -> onCreate ([#714](https://github.com/rivet-gg/rivetkit/issues/714)) ([3b9b106](https://github.com/rivet-gg/rivetkit/commit/3b9b1069d55352545291e4ea593b05cd0b8f89f5)) +* rename rpcs -> actions ([#711](https://github.com/rivet-gg/rivetkit/issues/711)) ([8957e56](https://github.com/rivet-gg/rivetkit/commit/8957e560572e7594db03d9ea631bf32995a61bd0)) +* return server from nodejs `serve` ([#726](https://github.com/rivet-gg/rivetkit/issues/726)) ([be84bda](https://github.com/rivet-gg/rivetkit/commit/be84bda24e6e6df435abef44d82e5d7d893bde43)) +* show full subpath to value that cannot be serialized when setting invalid state ([#706](https://github.com/rivet-gg/rivetkit/issues/706)) ([a666bc3](https://github.com/rivet-gg/rivetkit/commit/a666bc37644966d7482f54370ab92c5b259136b9)) +* update changelog for 0.7.0 ([#715](https://github.com/rivet-gg/rivetkit/issues/715)) ([dba8085](https://github.com/rivet-gg/rivetkit/commit/dba808513f2fb42ebd84f0d1dd21b3798223fda1)) +* update changelog for 0.7.1 ([#731](https://github.com/rivet-gg/rivetkit/issues/731)) ([f2e0cb3](https://github.com/rivet-gg/rivetkit/commit/f2e0cb3b18131086765478812498e605d3be2ff8)) +* update lockfile ([7b61057](https://github.com/rivet-gg/rivetkit/commit/7b6105796a2bbec69d75dbd0cae717b2e8fd7827)) +* update platforms to support `RivetKitApp` ([#712](https://github.com/rivet-gg/rivetkit/issues/712)) ([576a101](https://github.com/rivet-gg/rivetkit/commit/576a101dcfcbe5c44ff771db1db64b275a68cf81)) + +## [0.7.0](https://github.com/rivet-gg/rivetkit/compare/v0.6.3...v0.7.0) (2025-03-16) + + +### ⚠ BREAKING CHANGES + +* rename onInitialize -> onCreate ([#714](https://github.com/rivet-gg/rivetkit/issues/714)) +* rename rpcs -> actions ([#711](https://github.com/rivet-gg/rivetkit/issues/711)) +* expose functional interface for actors ([#710](https://github.com/rivet-gg/rivetkit/issues/710)) + +### Features + +* **cli:** add `--skip-manager` flag on deploy ([#708](https://github.com/rivet-gg/rivetkit/issues/708)) ([f46776d](https://github.com/rivet-gg/rivetkit/commit/f46776d21f4c669d8f1d134743889d3591f12a5d)) +* expose functional interface for actors ([#710](https://github.com/rivet-gg/rivetkit/issues/710)) ([803133d](https://github.com/rivet-gg/rivetkit/commit/803133d9f7404db5479bf92635eafc1c9f99acef)) + + +### Bug Fixes + +* fix schedule logging schedule errors ([#709](https://github.com/rivet-gg/rivetkit/issues/709)) ([f336561](https://github.com/rivet-gg/rivetkit/commit/f336561e7427eb87ed4ee930d405cc571a2cd775)) + + +### Documentation + +* update docs for new changes ([#713](https://github.com/rivet-gg/rivetkit/issues/713)) ([fa990dd](https://github.com/rivet-gg/rivetkit/commit/fa990dd22fdfc7cefea8f140cbcd2fcf05025dea)) + + +### Chores + +* add ws & eventsource as dev dependencies of rivetkit so it can build ([1cdf9c4](https://github.com/rivet-gg/rivetkit/commit/1cdf9c4351367a152224697029b047e5ef66518a)) +* fix typo of "Actor Core" -> "RivetKit" ([#707](https://github.com/rivet-gg/rivetkit/issues/707)) ([d1e8be5](https://github.com/rivet-gg/rivetkit/commit/d1e8be523fc75e1c55ad529bd85bc832a545b12a)) +* increase RPC timeout from 5s to 60s ([#705](https://github.com/rivet-gg/rivetkit/issues/705)) ([ec6a478](https://github.com/rivet-gg/rivetkit/commit/ec6a478e9ffff91028e8f2f718c79e65d3479354)) +* release 0.7.0 ([0a9b745](https://github.com/rivet-gg/rivetkit/commit/0a9b745f966379ed324be2a354d91999cb65e1f1)) +* release version 0.7.0 ([0fbc3da](https://github.com/rivet-gg/rivetkit/commit/0fbc3da0430581cc47543d2904c8241fa38d4f0e)) +* rename onInitialize -> onCreate ([#714](https://github.com/rivet-gg/rivetkit/issues/714)) ([3b9b106](https://github.com/rivet-gg/rivetkit/commit/3b9b1069d55352545291e4ea593b05cd0b8f89f5)) +* rename rpcs -> actions ([#711](https://github.com/rivet-gg/rivetkit/issues/711)) ([8957e56](https://github.com/rivet-gg/rivetkit/commit/8957e560572e7594db03d9ea631bf32995a61bd0)) +* show full subpath to value that cannot be serialized when setting invalid state ([#706](https://github.com/rivet-gg/rivetkit/issues/706)) ([a666bc3](https://github.com/rivet-gg/rivetkit/commit/a666bc37644966d7482f54370ab92c5b259136b9)) +* update changelog for 0.7.0 ([#715](https://github.com/rivet-gg/rivetkit/issues/715)) ([dba8085](https://github.com/rivet-gg/rivetkit/commit/dba808513f2fb42ebd84f0d1dd21b3798223fda1)) +* update platforms to support `RivetKitApp` ([#712](https://github.com/rivet-gg/rivetkit/issues/712)) ([576a101](https://github.com/rivet-gg/rivetkit/commit/576a101dcfcbe5c44ff771db1db64b275a68cf81)) + +## [0.6.3](https://github.com/rivet-gg/rivetkit/compare/v0.6.2...v0.6.3) (2025-03-13) + + +### Features + +* add client dispose method to clean up actor handles ([#686](https://github.com/rivet-gg/rivetkit/issues/686)) ([ff1e64d](https://github.com/rivet-gg/rivetkit/commit/ff1e64d952798f86cc4d67505a7fa2904749217b)) + + +### Bug Fixes + +* **client:** fix fallback priority of websockets & eventsources ([#700](https://github.com/rivet-gg/rivetkit/issues/700)) ([86550a0](https://github.com/rivet-gg/rivetkit/commit/86550a0ca5838ab4cd0c5f3d4229f3031d037d10)) +* **client:** modify endpoint to start with `ws` and `wss` ([#690](https://github.com/rivet-gg/rivetkit/issues/690)) ([8aed4ce](https://github.com/rivet-gg/rivetkit/commit/8aed4ceba6724d85c091a7660e5addcd7308c5cd)) +* **cli:** escape combined command, allow npx to install pkg ([#695](https://github.com/rivet-gg/rivetkit/issues/695)) ([0f173e6](https://github.com/rivet-gg/rivetkit/commit/0f173e68c074236fd59437574b9c5f499db8d55d)) +* **cli:** force to use npx when calling @rivet-gg/cli ([#698](https://github.com/rivet-gg/rivetkit/issues/698)) ([7d3d1d9](https://github.com/rivet-gg/rivetkit/commit/7d3d1d99127d0373d29c33dedd16d3aeadf9e318)) +* correct "onwer" typo to "owner" in deploy command ([#694](https://github.com/rivet-gg/rivetkit/issues/694)) ([cbc1255](https://github.com/rivet-gg/rivetkit/commit/cbc1255ae73ce9be07bfc80e97dd61f868579769)) +* implement schedule event saving functionality ([#687](https://github.com/rivet-gg/rivetkit/issues/687)) ([59f78f3](https://github.com/rivet-gg/rivetkit/commit/59f78f39a6cfd5d050d5359bbc224a6d7a2a3ea8)) +* remove use of .disconnect in example ([382ddb8](https://github.com/rivet-gg/rivetkit/commit/382ddb84cb14f6d22edf55281da4b4c030bfeb44)) +* skip CORS for WebSocket routes ([#703](https://github.com/rivet-gg/rivetkit/issues/703)) ([d51d618](https://github.com/rivet-gg/rivetkit/commit/d51d618c7f40daeead28716194534ab944293fbd)) +* use app.notFound instead of app.all("*") for 404 handling ([#701](https://github.com/rivet-gg/rivetkit/issues/701)) ([727dd28](https://github.com/rivet-gg/rivetkit/commit/727dd280c84e0d09928f62d4b99531d58900f865)) + + +### Chores + +* add explicit error handling for all hono routes ([#702](https://github.com/rivet-gg/rivetkit/issues/702)) ([365de24](https://github.com/rivet-gg/rivetkit/commit/365de24b75061eee931f473414c221286c6e0684)) +* changelog for 0.6.3 ([cf6d723](https://github.com/rivet-gg/rivetkit/commit/cf6d723a081029e8241a643186d41a09701192bd)) +* fix grammar on index ([#689](https://github.com/rivet-gg/rivetkit/issues/689)) ([dac5660](https://github.com/rivet-gg/rivetkit/commit/dac566058490c28ad34511dcee77c962602c6a3e)) +* **memory:** explicitly pass global state to memory driver ([#688](https://github.com/rivet-gg/rivetkit/issues/688)) ([542bd1c](https://github.com/rivet-gg/rivetkit/commit/542bd1c22b5d8844410bd9d3ae970162a6b481f2)) +* release 0.6.3 ([e06db47](https://github.com/rivet-gg/rivetkit/commit/e06db47aba656e47a721376e767dece5b0cd2934)) + +## [0.6.2](https://github.com/rivet-gg/rivetkit/compare/v0.6.1...v0.6.2) (2025-03-13) + + +### Features + +* add inpector ([#676](https://github.com/rivet-gg/rivetkit/issues/676)) ([a38c3af](https://github.com/rivet-gg/rivetkit/commit/a38c3af13aace93ddd0d3e488de10737ae9741b3)) +* add skip-install flag to create command ([#673](https://github.com/rivet-gg/rivetkit/issues/673)) ([71dbd10](https://github.com/rivet-gg/rivetkit/commit/71dbd105fe16f3453e3d837920cea8217277bd1d)) +* **cli:** tests ([#671](https://github.com/rivet-gg/rivetkit/issues/671)) ([44d1f7b](https://github.com/rivet-gg/rivetkit/commit/44d1f7ba378d8c44c9e95987d5986af0d6e55b4a)) + + +### Bug Fixes + +* **cli:** adjust deploy command to use proper lib ([#681](https://github.com/rivet-gg/rivetkit/issues/681)) ([037ed55](https://github.com/rivet-gg/rivetkit/commit/037ed55a3939863f12d9acae4c3c04b5c3ec0720)) +* **cli:** improve examples, and create-actor help, reduce information overload when deploying ([#670](https://github.com/rivet-gg/rivetkit/issues/670)) ([2f19149](https://github.com/rivet-gg/rivetkit/commit/2f19149218f3a645d647bc6d97755313222886b0)) + + +### Chores + +* bump required rivet cli version to 25.2.0 ([#679](https://github.com/rivet-gg/rivetkit/issues/679)) ([e31e921](https://github.com/rivet-gg/rivetkit/commit/e31e92144f04a9f10e04af813ebb32c8a368744b)) +* **main:** release 0.7.0 ([#678](https://github.com/rivet-gg/rivetkit/issues/678)) ([6a61617](https://github.com/rivet-gg/rivetkit/commit/6a616178cd4b9ed5d465e3cd44a8791023ee0fe2)) +* release 0.6.2 ([4361f9e](https://github.com/rivet-gg/rivetkit/commit/4361f9ea3bbd1da97f51b39772f4d9cc410cb86c)) +* release version 0.6.2 ([677bda2](https://github.com/rivet-gg/rivetkit/commit/677bda2f934ca2a26a1579aeefa871145ecaaecb)) +* update lockfile ([7b61057](https://github.com/rivet-gg/rivetkit/commit/7b6105796a2bbec69d75dbd0cae717b2e8fd7827)) + +## [0.6.1](https://github.com/rivet-gg/rivetkit/compare/v0.6.0...v0.6.1) (2025-03-05) + + +### Chores + +* **publish:** add build step to publish script ([3c43e26](https://github.com/rivet-gg/rivetkit/commit/3c43e26279e74cef941b2c98853c850951ccf2de)) +* **publish:** add build step to publish script ([#667](https://github.com/rivet-gg/rivetkit/issues/667)) ([3c43e26](https://github.com/rivet-gg/rivetkit/commit/3c43e26279e74cef941b2c98853c850951ccf2de)) +* release 0.6.1 ([5e817f6](https://github.com/rivet-gg/rivetkit/commit/5e817f63a5397c8dba1cfb5e45ed814150f77233)) +* release 0.6.1 ([3c43e26](https://github.com/rivet-gg/rivetkit/commit/3c43e26279e74cef941b2c98853c850951ccf2de)) +* release 0.6.1-rc.1 ([3c43e26](https://github.com/rivet-gg/rivetkit/commit/3c43e26279e74cef941b2c98853c850951ccf2de)) +* release version 0.6.1 ([3c43e26](https://github.com/rivet-gg/rivetkit/commit/3c43e26279e74cef941b2c98853c850951ccf2de)) +* release version 0.6.1-rc.1 ([3c43e26](https://github.com/rivet-gg/rivetkit/commit/3c43e26279e74cef941b2c98853c850951ccf2de)) + +## [0.6.0](https://github.com/rivet-gg/rivetkit/compare/v0.4.0...v0.6.0) (2025-03-05) + + +### Features + +* **rivetkit/cli:** add cli ([#642](https://github.com/rivet-gg/rivetkit/issues/642)) ([d919f1a](https://github.com/rivet-gg/rivetkit/commit/d919f1aa11972f0513f6ad5851965b7f469624cd)) +* add config validation ([#648](https://github.com/rivet-gg/rivetkit/issues/648)) ([3323988](https://github.com/rivet-gg/rivetkit/commit/3323988f6ab3d5d9ba99ba113f6b8e4a7f4c5ec7)) +* add cors support ([#647](https://github.com/rivet-gg/rivetkit/issues/647)) ([ef13939](https://github.com/rivet-gg/rivetkit/commit/ef13939f57c333d19b1cafc29b003bce1ccb8cf9)) +* add release candidate support to publish script ([#660](https://github.com/rivet-gg/rivetkit/issues/660)) ([f6c6adc](https://github.com/rivet-gg/rivetkit/commit/f6c6adc8dd8fe9ceb237ba55be7f5953fe8047ec)) +* **create-actor:** add create-actor lib ([#641](https://github.com/rivet-gg/rivetkit/issues/641)) ([05b5894](https://github.com/rivet-gg/rivetkit/commit/05b5894d4ca84f3b76f4a6fb6fa2ff4c6a5f9372)) +* support transport negotiation between client and server ([#636](https://github.com/rivet-gg/rivetkit/issues/636)) ([a6fa986](https://github.com/rivet-gg/rivetkit/commit/a6fa986b657e7fa294c95fb95cc51cc7930651be)) + + +### Bug Fixes + +* exclude create-actor from non-core packages validation ([#656](https://github.com/rivet-gg/rivetkit/issues/656)) ([2f2e1da](https://github.com/rivet-gg/rivetkit/commit/2f2e1daa4fdb5643b389c6fb261c96a0f37471fa)) +* update yarn.lock deps ([#655](https://github.com/rivet-gg/rivetkit/issues/655)) ([39958ab](https://github.com/rivet-gg/rivetkit/commit/39958abb0387e3b6a83bc13613665d2ec44b129b)) + + +### Documentation + +* add changelog ([#651](https://github.com/rivet-gg/rivetkit/issues/651)) ([4931a2a](https://github.com/rivet-gg/rivetkit/commit/4931a2a2e7eb244791f48508ee94d50dc1ea401e)) +* add changelog for v0.6.0 ([#661](https://github.com/rivet-gg/rivetkit/issues/661)) ([22fa68c](https://github.com/rivet-gg/rivetkit/commit/22fa68c092614fbb61228fcd96a84af9292d648c)) +* add llm resources ([#653](https://github.com/rivet-gg/rivetkit/issues/653)) ([de201a4](https://github.com/rivet-gg/rivetkit/commit/de201a4b4796fc43fc4cb330e1e1e5bec1b4d239)) +* fix private method name in schedule example ([#643](https://github.com/rivet-gg/rivetkit/issues/643)) ([8ada3a7](https://github.com/rivet-gg/rivetkit/commit/8ada3a7e13f564ae0135861951703778d72a39c4)) +* new landing page ([#630](https://github.com/rivet-gg/rivetkit/issues/630)) ([b8e4a8b](https://github.com/rivet-gg/rivetkit/commit/b8e4a8b1c7a5311372faa00aeeb5a883c762032b)) +* replace managing actors with building actors & interacting with actors ([436d76c](https://github.com/rivet-gg/rivetkit/commit/436d76c2de133bc1337d9e2240e274a2060540d6)) +* replace managing actors with building actors & interacting with actors ([#654](https://github.com/rivet-gg/rivetkit/issues/654)) ([436d76c](https://github.com/rivet-gg/rivetkit/commit/436d76c2de133bc1337d9e2240e274a2060540d6)) +* update Bluesky profile URL ([#644](https://github.com/rivet-gg/rivetkit/issues/644)) ([5e4d5ee](https://github.com/rivet-gg/rivetkit/commit/5e4d5eec962ab0e243fc99561b5179c351f222dd)) +* update changelog for add your own driver ([#652](https://github.com/rivet-gg/rivetkit/issues/652)) ([dc17dd1](https://github.com/rivet-gg/rivetkit/commit/dc17dd1702a72680a8830841cb10005840ecd036)) +* update feature comparison table ([#640](https://github.com/rivet-gg/rivetkit/issues/640)) ([237784e](https://github.com/rivet-gg/rivetkit/commit/237784ed69c67a3578c4e51f989ad8816092cefa)) +* update quickstart guide ([436d76c](https://github.com/rivet-gg/rivetkit/commit/436d76c2de133bc1337d9e2240e274a2060540d6)) +* update Rivet documentation links ([#664](https://github.com/rivet-gg/rivetkit/issues/664)) ([1ab1947](https://github.com/rivet-gg/rivetkit/commit/1ab194738a4448f10afab55a2b37c8326e6d66ee)) + + +### Code Refactoring + +* move redis p2p logic to generic driver ([#645](https://github.com/rivet-gg/rivetkit/issues/645)) ([35c5f71](https://github.com/rivet-gg/rivetkit/commit/35c5f71d4a2b17f699c348c8a1cd80589cf40af7)) + + +### Chores + +* add aider to gitignore ([#635](https://github.com/rivet-gg/rivetkit/issues/635)) ([b8cedf2](https://github.com/rivet-gg/rivetkit/commit/b8cedf2c6cec502abdda37f4c4d142a62fbfbc02)) +* add commit logging to publish script ([#657](https://github.com/rivet-gg/rivetkit/issues/657)) ([6d9b73b](https://github.com/rivet-gg/rivetkit/commit/6d9b73be7c4dd475a02c79eead584bda85348bf5)) +* add docs-bump command ([0d9ebb8](https://github.com/rivet-gg/rivetkit/commit/0d9ebb8f64a32005e12db808149f63832f197cfd)) +* bump mintlify ([6e88f31](https://github.com/rivet-gg/rivetkit/commit/6e88f312bb6535b271ce7aeb3e9dafc8ad7a9c3a)) +* bump mintlify ([64b99e4](https://github.com/rivet-gg/rivetkit/commit/64b99e4178ae2a61a62c0d0874524bcb78b296d0)) +* bump mintlify ([42a1d83](https://github.com/rivet-gg/rivetkit/commit/42a1d83ec26019f31ab0a0258553f9a3c8833cb5)) +* bump mintlify ([e6f0263](https://github.com/rivet-gg/rivetkit/commit/e6f026379e51b95e4164e4f818718e0128defa18)) +* **cloudflare-workers:** export ActorHandle with createRouter ([#649](https://github.com/rivet-gg/rivetkit/issues/649)) ([8c226be](https://github.com/rivet-gg/rivetkit/commit/8c226be3a95909ab2d65b0c4b21a1fb9b4050e2d)) +* docs-bump command ([1d93be1](https://github.com/rivet-gg/rivetkit/commit/1d93be161db0b55dc7559cd4c57d602b17ff0dc0)) +* **publish:** improve git push error handling ([6209d07](https://github.com/rivet-gg/rivetkit/commit/6209d0745560588863789679ffa7eb2c506c1bfd)) +* **publish:** improve git push error handling ([#659](https://github.com/rivet-gg/rivetkit/issues/659)) ([6209d07](https://github.com/rivet-gg/rivetkit/commit/6209d0745560588863789679ffa7eb2c506c1bfd)) +* release 0.5.0 ([6e3aa0b](https://github.com/rivet-gg/rivetkit/commit/6e3aa0bb9f2d9c1329cc019a7e4d7dbd565f33e6)) +* release 0.6.0 ([df72a82](https://github.com/rivet-gg/rivetkit/commit/df72a82d9186002770abd67fa192392be506b1ab)) +* release 0.6.0-rc.1 ([6209d07](https://github.com/rivet-gg/rivetkit/commit/6209d0745560588863789679ffa7eb2c506c1bfd)) +* release 0.6.0-rc.1 ([9f015f8](https://github.com/rivet-gg/rivetkit/commit/9f015f8b4c2b558408fe4f3e317a1efa765c82b6)) +* release 0.6.0-rc.1 ([6794336](https://github.com/rivet-gg/rivetkit/commit/6794336a3bab3aaefe19179b06a65cc31ecfeeef)) +* release version 0.5.0 ([cec9ae1](https://github.com/rivet-gg/rivetkit/commit/cec9ae1eae345d1828d7a2a56f525477c7aff2ca)) +* release version 0.5.0 ([2f9766f](https://github.com/rivet-gg/rivetkit/commit/2f9766fa598647d23e210828e91a39732810ceb7)) +* release version 0.6.0 ([bb97593](https://github.com/rivet-gg/rivetkit/commit/bb97593d95878a09b37f51b14bc5dbe14e91d117)) +* release version 0.6.0-rc.1 ([8a92416](https://github.com/rivet-gg/rivetkit/commit/8a92416e0006c6fe39bb57d5b275d8d67fc85299)) +* **release:** check for changes before version commit ([9f015f8](https://github.com/rivet-gg/rivetkit/commit/9f015f8b4c2b558408fe4f3e317a1efa765c82b6)) +* **release:** check for changes before version commit ([#658](https://github.com/rivet-gg/rivetkit/issues/658)) ([9f015f8](https://github.com/rivet-gg/rivetkit/commit/9f015f8b4c2b558408fe4f3e317a1efa765c82b6)) +* **release:** check if package already published before publishing ([#650](https://github.com/rivet-gg/rivetkit/issues/650)) ([9cddff4](https://github.com/rivet-gg/rivetkit/commit/9cddff4c4a157ad02208fbef58123c6677c16b3b)) +* switch docs middleware to production URL ([#632](https://github.com/rivet-gg/rivetkit/issues/632)) ([4698d60](https://github.com/rivet-gg/rivetkit/commit/4698d604311501b4d784175fb2759dff84a72f83)) +* update platform guides for create-actor ([#662](https://github.com/rivet-gg/rivetkit/issues/662)) ([09626c0](https://github.com/rivet-gg/rivetkit/commit/09626c01df4c017bef0896ba02cb338a268a0357)) +* update readme for new quickstart ([#663](https://github.com/rivet-gg/rivetkit/issues/663)) ([572a6ef](https://github.com/rivet-gg/rivetkit/commit/572a6eff8d90e63b4647b21fb00c2e0ed25deb7b)) +* update rivet links ([#634](https://github.com/rivet-gg/rivetkit/issues/634)) ([f5a19b3](https://github.com/rivet-gg/rivetkit/commit/f5a19b3c190387967e3f18c99c54edfbddf685fb)) + +## [0.4.0](https://github.com/rivet-gg/rivetkit/compare/v0.2.0...v0.4.0) (2025-02-13) + + +### Features + +* add connection retry with backoff ([#625](https://github.com/rivet-gg/rivetkit/issues/625)) ([a0a59a6](https://github.com/rivet-gg/rivetkit/commit/a0a59a6387e56f010d7f4df4c3385a76880c6222)) +* **bun:** bun support ([#623](https://github.com/rivet-gg/rivetkit/issues/623)) ([003a8a7](https://github.com/rivet-gg/rivetkit/commit/003a8a761638e036d6edc431f5c7374923828964)) +* **nodejs:** add nodejs support ([003a8a7](https://github.com/rivet-gg/rivetkit/commit/003a8a761638e036d6edc431f5c7374923828964)) + + +### Bug Fixes + +* keep NodeJS process alive with interval ([#624](https://github.com/rivet-gg/rivetkit/issues/624)) ([9aa2ace](https://github.com/rivet-gg/rivetkit/commit/9aa2ace064c8f9b0581e7f469c10d7d915d651a3)) + + +### Chores + +* add bun and nodejs packages to publish script ([#628](https://github.com/rivet-gg/rivetkit/issues/628)) ([b0367e6](https://github.com/rivet-gg/rivetkit/commit/b0367e66d3d5fb1894b85262eac8c2e0f678e2b4)) +* release 0.3.0-rc.1 ([16e25e8](https://github.com/rivet-gg/rivetkit/commit/16e25e8158489da127d269f354be651ccbad4ce5)) +* release 0.4.0 ([4ca17cd](https://github.com/rivet-gg/rivetkit/commit/4ca17cd39fdc2c07bfce56a4326454e16ecadd40)) +* release 0.4.0-rc.1 ([82ae37e](https://github.com/rivet-gg/rivetkit/commit/82ae37e38e08dba806536811d7bea7678e6380db)) +* release version 0.3.0-rc.1 ([5343b64](https://github.com/rivet-gg/rivetkit/commit/5343b648466b11fc048a20d1379e38538a442add)) +* release version 0.4.0 ([1f21931](https://github.com/rivet-gg/rivetkit/commit/1f2193113398f9a51aadcea84e4807ab7d2ed194)) +* release version 0.4.0-rc.1 ([9d6bf68](https://github.com/rivet-gg/rivetkit/commit/9d6bf68df08045c6e720b3132eb46c5324d0aa92)) +* update chat demo with topic ([#626](https://github.com/rivet-gg/rivetkit/issues/626)) ([7be4cfb](https://github.com/rivet-gg/rivetkit/commit/7be4cfb216f182c43d1e4b8500616d6a661f8006)) + +## [0.2.0](https://github.com/rivet-gg/rivetkit/compare/v24.6.2...v0.2.0) (2025-02-06) + + +### Features + +* sse conncetion driver ([#617](https://github.com/rivet-gg/rivetkit/issues/617)) ([8a2b0a3](https://github.com/rivet-gg/rivetkit/commit/8a2b0a3a0b07a0b4551c67fe7238da691d590892)) + + +### Bug Fixes + +* **cloudflare-workers:** accept requests proxied to actor without upgrade header ([#616](https://github.com/rivet-gg/rivetkit/issues/616)) ([71246d3](https://github.com/rivet-gg/rivetkit/commit/71246d38810a5ede89fc53458ccf1dae8357399b)) + + +### Code Refactoring + +* pass raw req to queryActor ([#613](https://github.com/rivet-gg/rivetkit/issues/613)) ([e919123](https://github.com/rivet-gg/rivetkit/commit/e919123b6d91497e68ea3b55f9ef10b10aff6f52)) + + +### Continuous Integration + +* add release please ([#614](https://github.com/rivet-gg/rivetkit/issues/614)) ([c95bcea](https://github.com/rivet-gg/rivetkit/commit/c95bceace69df54cf66bb4a339931dccb304c73e)) + + +### Chores + +* release 0.2.0 ([ed90143](https://github.com/rivet-gg/rivetkit/commit/ed901437203f87aa5345f91bc9a3c5f8517bbfcb)) +* release version 0.0.2 ([887af89](https://github.com/rivet-gg/rivetkit/commit/887af89414e5fb8cb283efbb6a6948756cf75bab)) +* release version 0.0.2 ([64b0cb4](https://github.com/rivet-gg/rivetkit/commit/64b0cb4830f66ac864e458fe0ab2d95a88271c8e)) +* release version 0.0.2 ([405b520](https://github.com/rivet-gg/rivetkit/commit/405b5201730f9faa8c21457b09fc2a62101e34e8)) +* release version 0.0.2 ([9e2d438](https://github.com/rivet-gg/rivetkit/commit/9e2d438f4b7533925151556f6290a4a50eee2ad6)) +* release version 0.0.3 ([951740e](https://github.com/rivet-gg/rivetkit/commit/951740e76efe44745168ef1443e7c42931a39e11)) +* release version 0.0.4 ([fbd865c](https://github.com/rivet-gg/rivetkit/commit/fbd865ccca93a17e24780974f4e4bac2456ae13d)) +* release version 0.0.5 ([1b4e780](https://github.com/rivet-gg/rivetkit/commit/1b4e780d95092a93d879e45062e5c690199fb6f8)) +* release version 0.0.6 ([375a709](https://github.com/rivet-gg/rivetkit/commit/375a70965756e432b975a6cff0f49d07430023f2)) +* release version 0.1.0 ([b797be8](https://github.com/rivet-gg/rivetkit/commit/b797be80da2dbff153645585ac3063bbb4651eba)) +* rename `ProtocolFormat` -> `Encoding` ([#618](https://github.com/rivet-gg/rivetkit/issues/618)) ([69ed424](https://github.com/rivet-gg/rivetkit/commit/69ed42467ccd85a807cc1cd52f6a81584d0a430f)) +* update images ([5070663](https://github.com/rivet-gg/rivetkit/commit/5070663b2dc5baaa375f9b777295e82ad458188f)) +* update release commit format ([#615](https://github.com/rivet-gg/rivetkit/issues/615)) ([f7bf62d](https://github.com/rivet-gg/rivetkit/commit/f7bf62d37a647383b33e2fb5191d1759a98a1101)) +* updated logos and hero ([3e8c99e](https://github.com/rivet-gg/rivetkit/commit/3e8c99ee207b7a9006f418d04561920b66faeef1)) diff --git a/CLAUDE.md b/CLAUDE.md index b5e6b5d60..58eff9b27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,154 +1,213 @@ -# agentOS +# RivetKit Development Guide -A high-level wrapper around the Secure-Exec OS that provides a clean API for running coding agents inside isolated VMs via the Agent Communication Protocol (ACP). +## Project Naming -## Secure-Exec (the underlying OS) +- Use `RivetKit` when referring to the project in documentation and plain English +- Use `rivetkit` when referring to the project in code, package names, and imports -Secure-Exec is an in-process operating system kernel written in JavaScript. All runtimes make "syscalls" into this kernel for file I/O, process spawning, networking, etc. The kernel orchestrates three execution environments: +## `packages/**/package.json` -- **WASM processes** — A custom libc and Rust toolchain compile a full suite of POSIX utilities (coreutils, sh, grep, etc.) to WebAssembly. WASM processes run in Worker threads and make synchronous syscalls to the kernel via SharedArrayBuffer RPC. -- **Node.js (V8 isolates)** — A sandboxed reimplementation of Node.js APIs (`child_process`, `fs`, `net`, etc.) runs JS/TS inside isolated V8 contexts. Module loading is hijacked to route through the kernel VFS. This is how agent code runs. -- **Python (Pyodide)** — CPython compiled to WASM via Pyodide, running in a Worker thread with kernel-backed file/network I/O. +- Always include relevant keywords for the packages +- All packages that are libraries should depend on peer deps for: @rivetkit/*, @hono/*, hono -All three runtimes implement the `RuntimeDriver` interface and are mounted into the kernel at boot. Processes can spawn children across runtimes (e.g., a Node process can spawn a WASM shell). +## `packages/**/README.md` -### Key subsystems +Always include a README.md for new packages. The `README.md` should always follow this structure: -- **Virtual filesystem (VFS)** — Layered chunked architecture: `ChunkedVFS` composes `FsMetadataStore` (directory tree, inodes, chunk mapping) + `FsBlockStore` (key-value blob store) into a `VirtualFileSystem`. Tiered storage keeps small files inline in metadata; larger files are split into chunks in the block store. The device layer (`/dev/null`, `/dev/urandom`, `/dev/pts/*`, etc.), proc layer (`/proc/[pid]/*`), and permission wrapper sit on top. All layers implement the `VirtualFileSystem` interface with full POSIX semantics. -- **Process management** — Kernel-wide process table tracks PIDs across all runtimes. Full POSIX process model: parent/child relationships, process groups, sessions, signals (SIGCHLD, SIGTERM, SIGWINCH), zombie cleanup, and `waitpid`. Each process gets its own FD table (0-255) with refcounted file descriptions supporting dup/dup2. -- **Pipes & PTYs** — Kernel-managed pipes (64KB buffers) enable cross-runtime IPC. PTY master/slave pairs with line discipline support interactive shells. `openShell()` allocates a PTY and spawns sh/bash. -- **Networking** — Socket table manages TCP/UDP/Unix domain sockets. Loopback connections stay entirely in-kernel. External connections delegate to a `HostNetworkAdapter` (implemented via `node:net`/`node:dgram` on the host). DNS resolution also goes through the adapter. -- **Permissions** — Deny-by-default access control. Four permission domains: `fs`, `network`, `childProcess`, `env`. Each is a function that returns `{allow, reason}`. The `allowAll` preset grants everything (used in agentOS). + ```md + # RivetKit {subname, e.g. library: Rivet Actor, driver and platform: RivetKit Redis Adapter, RivetKit Cloudflare Workers Adapter} -### What agentOS adds on top + _Lightweight Libraries for Backends_ -agentOS wraps the kernel and adds: a high-level filesystem/process API, ACP agent sessions (JSON-RPC over stdio), and a `ModuleAccessFileSystem` overlay that projects host `node_modules/` into the VM read-only so agents have access to their dependencies. + [Learn More →](https://github.com/rivet-gg/rivetkit) -## Project Structure + [Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) + + ## License + + Apache 2.0 + ``` + +## Common Terminology + +- **Actor**: A stateful, long-lived entity that processes messages and maintains state +- **Manager**: Component responsible for creating, routing, and managing actor instances +- **Remote Procedure Call (RPC)**: Method for an actor to expose callable functions to clients +- **Event**: Asynchronous message sent from an actor to connected clients +- **Alarm**: Scheduled callback that triggers at a specific time + +### Coordinated Topology Terminology + +- **Peer**: Individual actor instance in a coordinated network +- **Node**: Physical or logical host running one or more actor peers + +## Build Commands + +Run these commands from the root of the project. They depend on Turborepo, so you cannot run the commands within the package itself. Running these commands are important in order to ensure that all dependencies are automatically built. + +- **Type Check:** `pnpm check-types` - Verify TypeScript types +- **Check specific package:** `pnpm check-types -F rivetkit` - Check only specified package +- **Build:** `pnpm build` - Production build using Turbopack +- **Build specific package:** `pnpm build -F rivetkit` - Build only specified package +- **Format:** `pnpm fmt` - Format code with Biome + - Do not run the format command automatically. + +## Core Concepts + +### Topologies + +rivetkit supports three topologies that define how actors communicate and scale: + +- **Singleton:** A single instance of an actor running in one location +- **Partition:** Multiple instances of an actor type partitioned by ID, useful for horizontal scaling +- **Coordinate:** Actors connected in a peer-to-peer network, sharing state between instances -- **Monorepo**: pnpm workspaces + Turborepo + TypeScript + Biome (mirrors secure-exec) -- **Core package**: `@rivet-dev/agent-os-core` in `packages/core/` -- contains everything (VM ops, ACP client, session management) -- **Registry types**: `@rivet-dev/agent-os-registry-types` in `packages/registry-types/` -- shared type definitions for WASM command package descriptors. The agent-os-registry repo links to this package. When changing descriptor types, update here and rebuild the registry. -- **S3 block store**: `@rivet-dev/agent-os-s3` in `packages/s3/` -- S3-compatible `FsBlockStore` for cloud-persistent VFS storage. Pluggable driver, not imported by core. -- **npm scope**: `@rivet-dev/agent-os-*` -- **Actor integration** lives in the Rivet repo at `rivetkit-typescript/packages/rivetkit/src/agent-os/`, not as a separate package -- **The actor layer must maintain 1:1 feature parity with AgentOs.** Every public method on the `AgentOs` class (`packages/core/src/agent-os.ts`) must have a corresponding actor action in the Rivet repo's `rivetkit-typescript/packages/rivetkit/src/agent-os/`. Subscription methods (onProcessStdout, onShellData, onCronEvent, etc.) are wired through actor events. Lifecycle methods (dispose) are handled by the actor's onSleep/onDestroy hooks. When adding a new public method to AgentOs, add the corresponding actor action in the same change. -- **The RivetKit driver test suite must have full feature coverage of all agent-os actor actions.** Tests live in the Rivet repo's `rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/`. When adding a new actor action, add a corresponding driver test in the same change. -- **The core quickstart (`examples/quickstart/`) and the RivetKit example (in the Rivet repo at `examples/agent-os/`) must stay in sync.** Both cover the same set of features (hello-world, filesystem, processes, network, cron, tools, agent-session, sandbox) with identical behavior, just different APIs. Core uses `AgentOs.create()` directly; RivetKit uses `agentOs()` actor with client-server split. When adding or changing a quickstart example, update both. +### Driver Interfaces -## Terminology +Driver interfaces define the contract between rivetkit and various backends: -- Call instances of the OS **"VMs"**, never "sandboxes" +- **ActorDriver:** Manages actor state, lifecycle, and persistence +- **ManagerDriver:** Manages actor discovery, routing, and scaling +- **CoordinateDriver:** Handles peer-to-peer communication between actor instances + - Only applicable in coordinate topologies -## Architecture +### Driver Implementations + +Located in `packages/drivers/`, these implement the driver interfaces: + +- **Memory:** In-memory implementation for development and testing +- **Redis:** Production-ready implementation using Redis for persistence and pub/sub + +### Platforms + +Located in `packages/platforms/`, these adapt rivetkit to specific runtime environments: + +- **NodeJS:** Standard Node.js server environment +- **Cloudflare Workers:** Edge computing environment +- **Bun:** Fast JavaScript runtime alternative to Node.js +- **Rivet:** Cloud platform with built-in scaling and management + +## Package Import Resolution + +When importing from workspace packages, always check the package's `package.json` file under the `exports` field to determine the correct import paths: + +1. Locate the package's `package.json` file +2. Find the `exports` object which maps subpath patterns to their file locations +3. Use these defined subpaths in your imports rather than direct file paths +4. For example, if you need to import from a package, check its exports to find if it exposes specific subpaths for different modules + +This ensures imports resolve correctly across different build environments and prevents errors from direct file path imports that might change. + +## Code Style Guidelines + +- **Formatting:** Uses Biome for consistent formatting + - See biome.json for reference on formatting rules +- **Imports:** Organized imports enforced, unused imports warned +- **TypeScript:** Strict mode enabled, target ESNext +- **Naming:** + - camelCase for variables, functions + - PascalCase for classes, interfaces, types + - UPPER_CASE for constants + - Use `#` prefix for private class members (not `private` keyword) +- **Error Handling:** + - Extend from `ActorError` base class (packages/core/src/actor/errors.ts) + - 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 +- Use `logger()` to log messages + - Do not store `logger()` as a variable, always call it using `logger().info("...")` + - Use structured logging where it makes sense, for example: `logger().info("foo", { bar: 5, baz: 10 })` + - Supported logging methods are: trace, debug, info, warn, error, critical +- Instead of returning errors as raw HTTP responses with c.json, use or write an error in packages/rivetkit/src/actor/errors.ts and throw that instead. The middleware will automatically serialize the response for you. + +## Project Structure -- **Everything runs inside the VM.** Agent processes, servers, network requests -- all spawned inside the secure-exec kernel, never on the host. This is a hard rule with no exceptions. -- The `AgentOs` class wraps a secure-exec `Kernel` and proxies its API directly -- **All public methods on AgentOs must accept and return JSON-serializable data.** No object references (Session, ManagedProcess, ShellHandle) in the public API. Reference resources by ID (session ID, PID, shell ID). This keeps the API flat and portable across serialization boundaries (HTTP, RPC, IPC). -- Filesystem methods mirror the kernel API 1:1 (readFile, writeFile, mkdir, readdir, stat, exists, move, delete) -- **readdir returns `.` and `..` entries** — always filter them when iterating children to avoid infinite recursion -- Command execution mirrors the kernel API (exec, spawn) -- `fetch(port, request)` reaches services running inside the VM using the secure-exec network adapter pattern (`proc.network.fetch`) +- Monorepo with pnpm workspaces and Turborepo +- Core code in `packages/core/` +- Platform implementations in `packages/platforms/` +- Driver implementations in `packages/drivers/` -## Virtual Filesystem Design Reference +## Development Notes -- The VFS chunking and metadata architecture is modeled after **JuiceFS** (https://juicefs.com/docs/community/architecture/). Reference JuiceFS docs when designing chunk/block storage, metadata engine separation, or read/write data paths. -- Key JuiceFS concepts that apply: three-tier data model (Chunk/Slice/Block), pluggable metadata engines (SQLite, Redis, PostgreSQL), fixed-size block storage in object stores (S3), and metadata-data separation. -- For detailed design analysis: https://juicefs.com/en/blog/engineering/design-metadata-data-storage +- Use zod for runtime type validation +- Use `assertUnreachable(x: never)` for exhaustive type checking in switch statements +- Add proper JSDoc comments for public APIs +- Ensure proper error handling with descriptive messages +- Run `pnpm check-types` regularly during development to catch type errors early. Prefer `pnpm check-types` instead of `pnpm build`. +- Use `tsx` CLI to execute TypeScript scripts directly (e.g., `tsx script.ts` instead of `node script.js`). +- Do not auto-commit changes -### Agent-OS filesystem packages +## Test Guidelines -- The old `fs-sqlite` and `fs-postgres` packages were deleted. They are replaced by the secure-exec `SqliteMetadataStore` and the `ChunkedVFS` composition layer. -- **`packages/s3/`** (`@rivet-dev/agent-os-s3`): Exports `S3BlockStore` implementing the secure-exec `FsBlockStore` interface. Used with `ChunkedVFS(SqliteMetadataStore + S3BlockStore)` for cloud-persistent storage. Pluggable driver passed via `type: "custom"` mount, not imported by core. -- **`packages/google-drive/`** (`@rivet-dev/agent-os-google-drive`): Preview. Exports `GoogleDriveBlockStore` implementing the secure-exec `FsBlockStore` interface. Uses Google Drive API v3 via service account credentials. Tests are gated behind `GOOGLE_DRIVE_CLIENT_EMAIL`, `GOOGLE_DRIVE_PRIVATE_KEY`, and `GOOGLE_DRIVE_FOLDER_ID` environment variables. -- **`packages/sandbox/`** (`@rivet-dev/agent-os-sandbox`): Sandbox extension. Contains both `createSandboxFs()` (VirtualFileSystem backed by Sandbox Agent SDK) and `createSandboxToolkit()`. The old `fs-sandbox` package was merged into this. -- The Rivet actor integration (in the Rivet repo at `rivetkit-typescript/packages/rivetkit/src/agent-os/`) uses `ChunkedVFS(InMemoryMetadataStore + InMemoryBlockStore)` as a temporary in-memory solution. A persistent backend (actor KV-backed metadata + actor storage-backed blocks) is planned. +- Do not check if errors are an instanceOf ActorError in tests. Many error types do not have the same prototype chain when sent over the network, but still have the same properties so you can safely cast with `as`. -## Filesystem Conventions +## Examples -- **OS-level content uses mounts, not post-boot writes.** If agentOS needs custom directories in the VM (e.g., `/etc/agentos/`), mount a pre-populated filesystem at boot — don't create the kernel and then write files into it afterward. This keeps the root filesystem clean and makes OS-provided paths read-only so agents can't tamper with them. -- **Never interfere with the user's filesystem or code.** Don't write config files, instruction files, or metadata into the user's working directory or project tree. Use dedicated OS paths (`/etc/`, `/var/`, etc.) or CLI flags instead. If an agent framework requires a file in the project directory (e.g., OpenCode's context paths), prefer absolute paths to OS-managed locations over creating files in cwd. -- **Agent prompt injection must be non-destructive.** Each agent has its own mechanism for loading instructions (CLI flags, env vars, config files). When injecting OS instructions: preserve the agent's existing user-provided instructions (CLAUDE.md, AGENTS.md, etc.), append rather than replace, and always provide `skipOsInstructions` opt-out. User configuration is never clobbered — user env vars override ours via spread order. +Examples live in the `examples/` folder. -## Dependencies +### Example Configuration -- **secure-exec** is a `link:` dependency pointing to `~/secure-exec-1` (relative paths from each package) -- **Rivet repo** — A modifiable copy lives at `~/r-aos`. Use this when you need to make changes to the Rivet codebase. -- We can modify secure-exec as needed to fix issues or add missing APIs -- **Prefer implementing in secure-exec** when a feature is fundamentally an OS-level concern (filesystem, process management, networking). agentOS should be a thin wrapper, not a reimplementation. If adding something to secure-exec simplifies the agentOS implementation, do it there. -- **Fix root causes in secure-exec, not workarounds in agentOS.** If something is broken at the kernel/runtime level (PATH resolution, networking, process spawning), fix it in secure-exec. Don't add patchwork in agentOS to compensate for VM bugs. The only code in agentOS should be the high-level API surface and ACP session management. -- Mount host `node_modules` read-only for agent packages (pi-acp, etc.) +- All examples should have the turbo.json: -## Agent Sessions (ACP) + ```json + { + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] + } + ``` -- Uses the **Agent Communication Protocol** (ACP) -- JSON-RPC 2.0 over stdio (newline-delimited) -- No HTTP adapter layer; communicate directly with agent ACP adapters over stdin/stdout -- Reference `~/sandbox-agent` for ACP integration patterns (how pi-acp is spawned, JSON-RPC protocol, session lifecycle). Do not copy code from it. -- ACP docs: https://agentclientprotocol.com/get-started/introduction -- Session design is **agent-agnostic**: each agent type has a config specifying its ACP adapter package and main agent package name -- Currently configured agents: PI (`pi-acp`), OpenCode (`opencode-ai`). Only PI is tested. -- **OpenCode limitation**: OpenCode is a native ELF binary (compiled Go), not Node.js. The `opencode-ai` npm package is a wrapper that spawns the native binary. It cannot run inside the VM because the kernel only supports JS/WASM command execution. -- `createSession("pi")` spawns the ACP adapter inside the VM, which then spawns the agent +### `examples/*/package.json` -### Agent Configs +- Always name the example `example-{name}` +- Always use `workspace:*` for dependencies +- Use `tsx` unless otherwise instructed +- Always have a `dev` and `check-types` scripts + - `dev` should use `tsx --watch` unless otherwise instructed + - `check-types` should use `tsc --noEmit` -Each agent type needs: -- `acpAdapter`: npm package name for the ACP adapter (e.g., `pi-acp`) -- `agentPackage`: npm package name for the underlying agent (e.g., `@mariozechner/pi-coding-agent`) -- Any environment variables or flags needed +### `examples/*/README.md` -## Testing +Always include a README.md. The `README.md` should always follow this structure: -- **Framework**: vitest -- **All tests run inside the VM** -- network servers, file I/O, agent processes -- Network tests: write a server script file, run it with `node` inside the VM, then `vm.fetch()` against it -- Agent tests must be run sequentially in layers: - 1. PI headless mode (spawn pi directly, verify output) - 2. pi-acp manual spawn (JSON-RPC over stdio) - 3. Full `createSession()` API -- **API tokens**: All tests use `@copilotkit/llmock` with `ANTHROPIC_API_KEY='mock-key'`. No real API tokens needed. Do not load tokens from `~/misc/env.txt` or any external file. -- **Mock LLM testing**: Use `@copilotkit/llmock` to run a mock LLM server on the HOST (not inside the VM). Use `loopbackExemptPorts` in `AgentOs.create()` to exempt the mock port from SSRF checks. The kernel needs `permissions: allowAll` for network access. -- **Module access**: Set `moduleAccessCwd` in `AgentOs.create()` to a host dir with `node_modules/`. pnpm puts devDeps in `packages/core/node_modules/` which are accessible via the ModuleAccessFileSystem overlay. + ```md + # {human readable title} for RivetKit -### Known VM Limitations + Example project demonstrating {specific feature} with [RivetKit](https://rivetkit.org). -- `globalThis.fetch` is hardened (non-writable) in the VM — can't be mocked in-process -- Kernel child_process.spawn can't resolve bare commands from PATH (e.g., `pi`). Use `PI_ACP_PI_COMMAND` env var to point to the `.js` entry directly. The Node runtime resolves `.js`/`.mjs`/`.cjs` file paths as node scripts. -- `kernel.readFile()` does NOT see the ModuleAccessFileSystem overlay — read host files directly with `readFileSync` for package.json resolution -- Native ELF binaries cannot execute in the VM — the kernel's command resolver only handles `.js`/`.mjs`/`.cjs` scripts and WASM commands. `child_process.spawnSync` returns `{ status: 1, stderr: "ENOENT: command not found" }` for native binaries. + [Learn More →](https://github.com/rivet-gg/rivetkit) -### Debugging Policy + [Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) -- **Never guess without concrete logs.** Every assertion about what's happening at runtime must be backed by log output. If you don't have logs proving something, add them before making claims. Use logging liberally when debugging -- add logs at every decision point and trace the full execution path before drawing conclusions. Never assume something is a timeout issue unless there are logs proving the system was actively busy for the entire duration. An idle hang and a slow operation look the same from the outside -- only logs can distinguish them. -- **Never use CJS transpilation as a workaround** for ESM module loading issues. The VM must use V8's native ESM module system and Node.js native imports. Fix root causes in the ESM resolver, module access overlay, or V8 runtime instead of transforming ESM to CJS. The correct approach is to implement proper CJS/ESM interop in the V8 module resolver (wrapping CJS modules in ESM shims with named exports). -- **Maintain a friction log** at `.agent/notes/vm-friction.md` for anything that behaves differently from a standard POSIX/Node.js system. Document the deviation, the root cause, and whether a fix exists. + ## Getting Started -## Documentation + ### Prerequisites -- **Keep docs in `~/r-aos/docs/docs/agent-os/` up to date** when public API methods or types are added, removed, or changed on AgentOs or Session classes. -- **Keep `website/src/data/registry.ts` up to date.** When adding, removing, or renaming a package, update this file so the website reflects the current set of available apps (agents, file-systems, software, and sandbox providers). Every new agent-os package or agent-os-registry software package must have a corresponding entry. -- **No implementation details in user-facing docs.** Never mention WebAssembly, WASM, V8 isolates, Pyodide, or SQLite VFS in documentation outside of `architecture.mdx`. These are internal implementation details. Use user-facing language instead: "persistent filesystem" not "SQLite VFS", "JavaScript, TypeScript, Python, and shell commands" not "WASM, V8 isolates, and Pyodide", "sandboxed execution" not "WebAssembly and V8 isolates". The `architecture.mdx` page is the only place where internals are appropriate. + - {node or bun based on demo} + - {any other related services if this integrates with external SaaS} -## Software Registry + ### Installation -All WASM command source code and software packages live in `~/agent-os-registry/` (GitHub: `rivet-dev/agent-os-registry`). Software packages are in `software/` (not `packages/`). This includes the Rust and C source, build system, and npm package wrappers. Each package corresponds to a Debian/apt package name and publishes as `@rivet-dev/agent-os-{name}`. See `~/agent-os-registry/CLAUDE.md` for naming conventions, package types, and how to add new packages. No WASM command code remains in secure-exec. + ```sh + git clone https://github.com/rivet-gg/rivetkit + cd rivetkit/examples/{name} + npm install + ``` -The registry depends on `@rivet-dev/agent-os-registry-types` (in this repo at `packages/registry-types/`) via a link dependency. This is the single source of truth for descriptor types like `WasmCommandPackage` and `WasmMetaPackage`. + ### Development -## Ralph PRD + ```sh + npm run dev + ``` -The Ralph PRD is at `scripts/ralph/prd.json`. + {instructions to either open browser or run script to test it} -## Git + ## License -- **Commit messages**: Single-line conventional commits (e.g., `feat: add host tools RPC server`). No body, no co-author trailers. + Apache 2.0 + ``` -## Build & Dev +## Test Notes -```bash -pnpm install -pnpm build # turbo run build -pnpm test # turbo run test -pnpm check-types # turbo run check-types -pnpm lint # biome check -``` +- Using setTimeout in tests & test actors will not work unless you call `await waitFor(driverTestConfig, )` +- Do not use setTimeout in tests or in actors used in tests unless you explictily use `await vi.advanceTimersByTimeAsync(time)` \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..538c4eb60 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = [ + "clients/rust", + "clients/python", +] diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..c7fe6134f --- /dev/null +++ b/LICENSE @@ -0,0 +1,203 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Rivet Gaming, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/NEW_SPEC.md b/NEW_SPEC.md new file mode 100644 index 000000000..4dbee0847 --- /dev/null +++ b/NEW_SPEC.md @@ -0,0 +1,177 @@ +# Routing Refactor Spec + +## Prerequisites + +Make breaking changes as needed. Do not worry about backwards compatibility. + +## Current design + +Currently, there are 3 topologies. Requests through each topology look like (specifically for actions): + +Standalone: +- Manager router -> Auth & resolve actor ID -> Inline routing handler -> StandaloneTopology.getOrCreateActor() -> Direct method call on local actor instance +- Inline client -> Direct connection to local actor instance -> Executes action synchronously in same process + +Coordinated: +- Manager router -> Auth & resolve actor ID -> Inline routing handler -> CoordinateTopology.publishActionToLeader() -> Message sent via coordinate driver -> Leader peer executes action -> Response flows back +- Inline client -> Routes to local topology -> Forwards to leader peer via message passing -> Waits for acknowledgment with timeout + +Partition: +- Manager router -> Auth & resolve actor ID -> Custom routing handler -> Proxy request to partition URL -> Partition's Actor Router -> Executes on isolated actor instance +- Inline client -> Not used (partition uses custom routing handler that proxies to remote actor router) + +## Desired design + +We need to standardize all requests to have a standard HTTP interface to communicate with the actor. + +Manager router: +- HTTP request arrives -> Auth & resolve actor ID -> Create standard Request object -> ManagerDriver.onFetch(actorId, request) -> Driver-specific routing (local/remote/proxy) -> Actor Router handles request -> Execute action on actor instance -> Return Response + +Inline client: +- Client calls action/rpc -> Create standard Request object -> ActorDriver.sendRequest(actorId, request) -> Driver routes to actor (in-process/IPC/network) -> Actor Router handles request -> Execute on actor instance -> Return response to client + +See the existing routingHandler.custom for reference on code that's similar. + +## New Project Structure + +Completely remove topologies and usage of topologies. Completely remove uses of topology classes and interfaces. +Remove all uses of: + +- Topologies + - Move all topology-related functionality in to manager driver + - Any topology-specific settings are now part of the driver + - The ManagerDriver now handles actor lifecycle +- ConnRoutingHandler and ConnectionHandlers + - Everything behaves like ConnRoutingHandlerCustom now, by calling methods on ManagerDriver +- ConnRoutingHandlerCustom + - sendRequest, openWebSocket, proxyRequest, and proxyWebSocket are now part of ManagerDriver + +Add onFetch and onWebSocket to ManagerDriver. + +All configurations should be part of ActorDriver and ManagerDriver. + +Ensure that the following drivers are working: + +- Memory +- File system +- Cloudflare Workers + +Ignore: + +- Redis (types will not pass) + +Delete: + +- Rivet driver (currently commented out) + +Move all code for the coordinated driver to a separate package. Ignore this package for now, this will have compile errors, etc. + + +### Current Structure to Remove +``` +packages/core/src/ +├── topologies/ +│ ├── standalone/ +│ │ └── topology.ts +│ ├── partition/ +│ │ ├── topology.ts (split into manager/actor) +│ │ └── actor.ts +│ └── coordinate/ +│ ├── topology.ts +│ ├── driver.ts +│ ├── node/ +│ └── peer/ +├── actor/ +│ ├── conn-routing-handler.ts (remove) +│ └── router-endpoints.ts (ConnectionHandlers interface - remove) +└── manager/ + └── topology.ts (remove base topology) +``` + +### New Structure +``` +packages/core/src/ +├── actor/ +│ ├── driver.ts (enhanced with routing capabilities) +│ │ └── Add: sendRequest(actorId, request) method +│ ├── instance.ts (keep, minor updates) +│ ├── router.ts (new - handles actor-side request routing) +│ ├── config.ts (merge topology configs here) +│ └── errors.ts (keep) +├── manager/ +│ ├── driver.ts (enhanced with lifecycle + routing) +│ │ └── Add: onFetch(actorId, request) +│ │ └── Add: onWebSocket(actorId, request, socket) +│ │ └── Add: sendRequest(actorId, request) +│ │ └── Add: openWebSocket(actorId, request) +│ │ └── Add: proxyRequest(actorId, request) +│ │ └── Add: proxyWebSocket(actorId, request, socket) +│ │ └── Add: actor lifecycle methods from topologies +│ ├── router.ts (simplified - delegates to driver) +│ └── config.ts (merge topology configs here) +├── client/ +│ ├── http-client-driver.ts (update to use new routing) +│ └── inline-client-driver.ts (update to use ActorDriver.sendRequest) +├── common/ +│ └── request-response.ts (new - standard Request/Response interfaces) +└── driver-test-suite/ (update tests for new architecture) + +packages/drivers/memory/src/ +├── manager-driver.ts (implement new routing methods) +└── actor-driver.ts (implement sendRequest) + +packages/drivers/file-system/src/ +├── manager-driver.ts (implement new routing methods) +└── actor-driver.ts (implement sendRequest) + +packages/platforms/cloudflare-workers/src/ +├── manager-driver.ts (implement new routing methods) +└── actor-driver.ts (implement sendRequest) + +packages/coordinate/ (new package - move from core) +├── src/ +│ ├── driver.ts +│ ├── manager-driver.ts (implements ManagerDriver) +│ ├── actor-driver.ts (implements ActorDriver) +│ ├── node/ +│ ├── peer/ +│ └── mod.ts +└── package.json +``` + +### Additional Symbols to Delete + +1. **Routing Handler Types** (in `actor/conn-routing-handler.ts`): + - `BuildProxyEndpoint` type + - `SendRequestHandler` type (duplicate in partition/topology.ts) + - `OpenWebSocketHandler` type (duplicate in partition/topology.ts) + - `ProxyRequestHandler` type + - `ProxyWebSocketHandler` type + +2. **Configuration Types and Schemas**: + - `Topology` enum type (in `registry/run-config.ts`) + - `TopologySchema` (in `registry/run-config.ts`) + - `topology` field in `DriverConfigSchema` + - `connRoutingHandler` property in `ManagerDriver` interface + +3. **Internal Types**: + - `GlobalState` interface (in `coordinate/topology.ts`) + +4. **Registry Module Logic**: + - All topology setup logic in `registry/mod.ts` (lines 61-78 and 114-125) + - Topology exports from `topologies/mod.ts` + +### Symbols to Move to Coordinate Package + +1. **CoordinateDriver and Related Types**: + - `CoordinateDriver` interface (in `topologies/coordinate/driver.ts`) + - `NodeMessageCallback` type + - `GetActorLeaderOutput` interface + - `StartActorAndAcquireLeaseOutput` interface + - `ExtendLeaseOutput` interface + - `AttemptAcquireLease` interface + +2. **Coordinate-specific Configuration**: + - `ActorPeerConfig` and `ActorPeerConfigSchema` (in `registry/run-config.ts`) + - `actorPeer` field in `RunConfigSchema` + - `coordinate` field in `DriverConfigSchema` diff --git a/NEW_SPEC2.md b/NEW_SPEC2.md new file mode 100644 index 000000000..86180de61 --- /dev/null +++ b/NEW_SPEC2.md @@ -0,0 +1,8 @@ + +Manager router -> ManagerDriver.createActor -> save actor in memory +Manager router -> ManagerDriver.proxyRequest -> actorRouter.fetch(req, { Bindings: ... }) -> get actor ID from env -> ActorDriver.loadActor -> call actor action + +## todo + +[ ] fix genericconnectionglobalstate + diff --git a/README.md b/README.md index 9ae4aae2d..8e1e0e2cb 100644 --- a/README.md +++ b/README.md @@ -1,288 +1,248 @@ -# agentOS - -A high-level wrapper around [Secure-Exec](https://github.com/nichochar/secure-exec) that provides a clean API for running coding agents inside isolated VMs via the [Agent Communication Protocol](https://agentclientprotocol.com) (ACP). - -## Features - -- **Filesystem & process management** — read/write files, exec commands, spawn long-running processes -- **Agent sessions via ACP** — spawn coding agents (PI, OpenCode, etc.) inside the VM and communicate over JSON-RPC stdio -- **Module mounts** — project host `node_modules` into the VM read-only so agents have access to their dependencies -- **Host tools** — define toolkits on the host that agents can call via CLI commands inside the VM - -## How it works - -agentOS builds on **Secure-Exec**, an in-process operating system kernel written in JavaScript. The kernel manages a layered virtual filesystem (in-memory storage, `/dev` devices, `/proc` pseudo-files, permission checks), a POSIX process table with cross-runtime process trees, pipes, PTYs, and a virtual network stack with in-kernel loopback and host-delegated external connections. - -Three runtimes mount into the kernel: - -- **WASM** — A custom libc and Rust toolchain compile POSIX utilities (coreutils, sh, grep, etc.) to WebAssembly. Processes run in Worker threads with synchronous syscalls via SharedArrayBuffer. -- **Node.js** — A sandboxed reimplementation of Node.js APIs (`child_process`, `fs`, `net`) runs JS/TS inside isolated V8 contexts. Module loading routes through the kernel VFS. -- **Python** — CPython via Pyodide, running in a Worker thread with kernel-backed I/O. - -Everything — agent processes, servers, file I/O, network requests — runs inside the kernel. Nothing executes on the host. - -agentOS wraps this into a higher-level API that adds: - -- **Filesystem & process management** — read/write files, exec commands, spawn long-running processes -- **Agent sessions via ACP** — spawn coding agents (PI, OpenCode, etc.) inside the VM and communicate over JSON-RPC stdio -- **Module mounts** — project host `node_modules` into the VM read-only so agents have access to their dependencies -- **Host tools** — define tools on the host that agents invoke via auto-generated CLI commands inside the VM - -## Quick start - -```ts -import { AgentOs } from "@rivet-dev/agent-os-core"; - -// Boot a VM -const vm = await AgentOs.create(); - -// Run commands -await vm.exec("echo hello"); - -// Work with the filesystem -await vm.writeFile("/home/user/test.txt", "hello world"); -const content = await vm.readFile("/home/user/test.txt"); - -// Spawn an ACP coding agent session -const session = await vm.createSession("pi", { - env: { ANTHROPIC_API_KEY: "..." }, -}); -const response = await session.prompt("Write a hello world script"); - -await vm.dispose(); + + +
+ + + + RivetKit + + +
+
+

The open-source alternative to Durable Objects

+

+ RivetKit is a library for long-lived processes with durable state, realtime, and scalability.
+ Easily self-hostable and works with your infrastructure. +

+

+ Quickstart • + Documentation • + Self-Hosting • + Discord • + X • + Bluesky +

+

+ + Supports Node.js, Bun, Redis, Cloudflare,
+ React, Rust, Hono, Express, tRPC, and Better Auth. +
+

+
+ +## Projects + +Public-facing projects: + +- **RivetKit** (you are here): Lightweight TypeScript library for building Rivet Actors +- **[Rivet Engine](https://github.com/rivet-gg/rivet)** : Engine that powers Rivet Actors at scale — completely optional +- **[Rivet Studio](https://github.com/rivet-gg/rivet/tree/main/frontend/apps/studio)**: Like Postman, but for Rivet Actors +- **[Rivet Documentation](https://github.com/rivet-gg/rivet/tree/main/site/src/content/docs)** + +## Get Started + +### Guides + +Get started with Rivet by following a quickstart guide: + +- [Node.js & Bun](https://www.rivet.gg/docs/actors/quickstart/backend/) +- [React](https://www.rivet.gg/docs/actors/quickstart/react/) + + +### Quickstart + +**Step 1**: Install RivetKit + +```sh +npm install @rivetkit/actor ``` -## Host Tools - -Host tools let you define functions on the host that agents running inside the VM can call via CLI commands. Each tool belongs to a **toolkit** (a named group), and each toolkit becomes a CLI binary (`agentos-{name}`) available in the VM's `$PATH`. - -### Defining tools and toolkits - -Use `hostTool()` and `toolKit()` to define tools with full type inference from Zod schemas: - -```ts -import { z } from "zod"; -import { AgentOs, hostTool, toolKit } from "@rivet-dev/agent-os-core"; - -const fileTools = toolKit({ - name: "files", - description: "Host filesystem operations", - tools: { - read: hostTool({ - description: "Read a file from the host filesystem", - inputSchema: z.object({ - path: z.string().describe("Absolute path to the file"), - }), - execute: async ({ path }) => { - const content = await fs.readFile(path, "utf-8"); - return { content }; - }, - examples: [ - { description: "Read a config file", input: { path: "/etc/config.json" } }, - ], - timeout: 10000, // ms, default: 30000 - }), - search: hostTool({ - description: "Search files by pattern", - inputSchema: z.object({ - pattern: z.string().describe("Glob pattern"), - directory: z.string().describe("Directory to search in"), - recursive: z.boolean().optional().describe("Search subdirectories"), - }), - execute: async ({ pattern, directory, recursive }) => { - // ... search implementation - return { matches: [] }; - }, - }), - }, +**Step 2**: Create an actor + +```typescript +// registry.ts +import { actor, setup } from "@rivetkit/actor"; + +export const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount: number = 1) => { + // State changes are durable & automatically persisted + c.state.count += amount; + // Broadcast realtime events + c.broadcast("countChanged", c.state.count); + // Return data to client + return c.state.count; + }, + getCount: (c) => c.state.count, + }, }); -``` - -### Passing toolkits to AgentOs.create() -Toolkits are registered at VM creation and available to all sessions: - -```ts -const vm = await AgentOs.create({ - toolKits: [fileTools], +export const registry = setup({ + use: { counter }, }); ``` -### What the agent runs +Read more about [state](https://www.rivet.gg/docs/actors/state/), [actions](https://www.rivet.gg/docs/actors/actions/), and [events](https://www.rivet.gg/docs/actors/events/). -Inside the VM, each toolkit is available as a CLI command: `agentos-{name} [flags]`. +**Step 2**: Setup server -```bash -# Call the "read" tool in the "files" toolkit -agentos-files read --path /etc/config.json +_Alternatively, see the [React](https://www.rivet.gg/docs/actors/quickstart/react/) guide which does not require a server._ -# Call "search" with multiple flags -agentos-files search --pattern "*.ts" --directory /src --recursive +```typescript +// server.ts +import { registry } from "./registry"; +import { Hono } from "hono"; -# List all available toolkits -agentos list-tools +// Start with file system driver for development +const { client, serve } = registry.createServer(); -# List tools in a specific toolkit -agentos list-tools files +// Setup your server +const app = new Hono(); -# Get help for a toolkit or tool -agentos-files --help -agentos-files read --help -``` +app.post("/increment/:name", async (c) => { + const name = c.req.param("name"); -### Input modes + // Get or create actor with key `name` + const counter = client.counter.getOrCreate(name); -Tools can receive input in four ways: + // Call an action + const newCount = await counter.increment(1); -1. **CLI flags** — parsed against the Zod schema. Field names are converted to kebab-case (`--my-field value`). - ```bash - agentos-files read --path /etc/config.json - ``` + return c.json({ count: newCount }); +}); -2. **Inline JSON** — pass a JSON string directly. - ```bash - agentos-files read --json '{"path": "/etc/config.json"}' - ``` +// Start server with Rivet +serve(app); +``` -3. **JSON file** — read input from a file. - ```bash - agentos-files read --json-file /tmp/input.json - ``` +Start the server with: -4. **stdin** — pipe JSON via standard input (auto-detected when not a TTY). - ```bash - echo '{"path": "/etc/config.json"}' | agentos-files read - ``` +```typescript +npx tsx server.ts +// or +bun server.ts +``` -### Flag types +Read more about [clients](https://www.rivet.gg/docs/actors/clients/). -The CLI flag parser supports the following Zod types: +You can connect to your server with: -| Zod type | Flag syntax | Example | -|---|---|---| -| `z.string()` | `--name value` | `--path /etc/config.json` | -| `z.number()` | `--name 42` | `--count 5` | -| `z.boolean()` | `--flag` / `--no-flag` | `--recursive` / `--no-recursive` | -| `z.enum()` | `--name value` | `--format json` | -| `z.array(z.string())` | `--name a --name b` | `--tags foo --tags bar` | +```typescript +// client.ts +const response = await fetch("http://localhost:8080/increment/my-counter", { method: "POST" }); +const result = await response.json(); +console.log("Count:", result.count); // 1 +``` -Optional fields (`.optional()`) become optional flags. +**Step 3**: Deploy -### CLI shim pattern +To scale Rivet in production, follow a guide to deploy to a hosting provider or integrate a driver: -agentOS generates POSIX shell scripts that are mounted read-only at `/usr/local/bin/` inside the VM. These shims communicate with a host-side HTTP RPC server via `http-test` (a WASM binary available in the VM): +- [Redis](https://www.rivet.gg/docs/drivers/redis/) +- [Cloudflare Workers](https://www.rivet.gg/docs/hosting-providers/cloudflare-workers/) -- Each toolkit gets a shim: `/usr/local/bin/agentos-{name}` -- A master shim `/usr/local/bin/agentos` provides `list-tools` -- The RPC server port is passed via the `AGENTOS_TOOLS_PORT` environment variable -- Shims parse input and forward it to the host for execution -- Tool responses are JSON: `{ ok: true, result: ... }` or `{ ok: false, error: "...", message: "..." }` +## Features -### Error codes +RivetKit provides everything you need to build fast, scalable, and real-time applications without the complexity. -When a tool invocation fails, the response includes an error code: +- **Long-Lived, Stateful Compute**: Like AWS Lambda but with memory and no timeouts +- **Blazing-Fast Reads & Writes**: State stored on same machine as compute +- **Realtime, Made Simple**: Built-in WebSockets and SSE support +- **Store Data Near Your Users**: Deploy to the edge for low-latency access +- **Infinitely Scalable**: Auto-scale from zero to millions without configuration +- **Fault Tolerant**: Automatic error handling and recovery built-in -| Error code | Description | -|---|---| -| `TOOLKIT_NOT_FOUND` | The specified toolkit name does not exist | -| `TOOL_NOT_FOUND` | The specified tool does not exist in the toolkit | -| `VALIDATION_ERROR` | Input failed Zod schema validation or JSON parsing | -| `EXECUTION_ERROR` | The tool's `execute` function threw an error | -| `TIMEOUT` | The tool did not complete within its timeout (default 30s) | -| `INTERNAL_ERROR` | Server unreachable or unknown endpoint | +## Examples -## API Reference +- AI Agent — [GitHub](https://github.com/rivet-gg/rivetkit/tree/main/examples/ai-agent) · [StackBlitz](https://stackblitz.com/github/rivet-gg/rivetkit/tree/main/examples/ai-agent) +- Chat Room — [GitHub](https://github.com/rivet-gg/rivetkit/tree/main/examples/chat-room) · [StackBlitz](https://stackblitz.com/github/rivet-gg/rivetkit/tree/main/examples/chat-room) +- Collab (Yjs) — [GitHub](https://github.com/rivet-gg/rivetkit/tree/main/examples/crdt) · [StackBlitz](https://stackblitz.com/github/rivet-gg/rivetkit/tree/main/examples/crdt) +- Multiplayer Game — [GitHub](https://github.com/rivet-gg/rivetkit/tree/main/examples/game) · [StackBlitz](https://stackblitz.com/github/rivet-gg/rivetkit/tree/main/examples/game) +- Local-First Sync — [GitHub](https://github.com/rivet-gg/rivetkit/tree/main/examples/sync) · [StackBlitz](https://stackblitz.com/github/rivet-gg/rivetkit/tree/main/examples/sync) +- Rate Limiter — [GitHub](https://github.com/rivet-gg/rivetkit/tree/main/examples/rate) · [StackBlitz](https://stackblitz.com/github/rivet-gg/rivetkit/tree/main/examples/rate) +- Per-User DB — [GitHub](https://github.com/rivet-gg/rivetkit/tree/main/examples/database) · [StackBlitz](https://stackblitz.com/github/rivet-gg/rivetkit/tree/main/examples/database) +- Multi-Tenant SaaS — [GitHub](https://github.com/rivet-gg/rivetkit/tree/main/examples/tenant) · [StackBlitz](https://stackblitz.com/github/rivet-gg/rivetkit/tree/main/examples/tenant) +- Stream Processing — [GitHub](https://github.com/rivet-gg/rivetkit/tree/main/examples/stream) · [StackBlitz](https://stackblitz.com/github/rivet-gg/rivetkit/tree/main/examples/stream) -### Core +## Runs Anywhere -#### `AgentOs.create(options?)` +Deploy RivetKit anywhere - from serverless platforms to your own infrastructure with RivetKit's flexible runtime options. Don't see the runtime you want? [Add your own](https://rivetkit.org/drivers/build). -Create a new VM instance. +### All-In-One +- Rivet  [Rivet](https://rivetkit.org/drivers/rivet) +- Cloudflare Workers  [Cloudflare Workers](https://rivetkit.org/drivers/cloudflare-workers) -- **`options.toolKits`** — `ToolKit[]` — Toolkits available to all sessions in this VM. -- **`options.moduleAccessCwd`** — `string` — Host directory with `node_modules/` to mount read-only. -- **`options.permissions`** — Kernel permission configuration. -- **`options.loopbackExemptPorts`** — `number[]` — Ports exempt from SSRF checks. +### Compute +- Vercel  [Vercel](https://github.com/rivet-gg/rivetkit/issues/897) *(On The Roadmap)* +- AWS Lambda  [AWS Lambda](https://github.com/rivet-gg/rivetkit/issues/898) *(On The Roadmap)* +- Supabase  [Supabase](https://github.com/rivet-gg/rivetkit/issues/905) *(Help Wanted)* +- Bun  [Bun](https://rivetkit.org/actors/quickstart-backend) +- Node.js  [Node.js](https://rivetkit.org/actors/quickstart-backend) -#### `vm.createSession(agent, options?)` +### Storage +- Redis  [Redis](https://rivetkit.org/drivers/redis) +- Postgres  [Postgres](https://github.com/rivet-gg/rivetkit/issues/899) *(Help Wanted)* +- File System  [File System](https://rivetkit.org/drivers/file-system) +- Memory  [Memory](https://rivetkit.org/drivers/memory) -Create an ACP agent session. +## Works With Your Tools -- **`options.env`** — `Record` — Environment variables for the agent process. +Seamlessly integrate RivetKit with your favorite frameworks, languages, and tools. Don't see what you need? [Request an integration](https://github.com/rivet-gg/rivetkit/issues/new). -### Host Tools Types +### Frameworks +- React  [React](https://rivetkit.org/clients/react) +- Next.js  [Next.js](https://github.com/rivet-gg/rivetkit/issues/904) *(Help Wanted)* +- Vue  [Vue](https://github.com/rivet-gg/rivetkit/issues/903) *(Help Wanted)* -#### `HostTool` +### Clients +- JavaScript  [JavaScript](https://rivetkit.org/clients/javascript) +- TypeScript  [TypeScript](https://rivetkit.org/clients/javascript) +- Python  [Python](https://github.com/rivet-gg/rivetkit/issues/902) *(Help Wanted)* +- Rust  [Rust](https://github.com/rivet-gg/rivetkit/issues/901) *(Help Wanted)* -```ts -interface HostTool { - description: string; - inputSchema: ZodType; - execute: (input: INPUT) => Promise | OUTPUT; - examples?: ToolExample[]; - timeout?: number; // ms, default: 30000 -} -``` +### Integrations +- Hono  [Hono](https://rivetkit.org/integrations/hono) +- Vitest  [Vitest](https://rivetkit.org/general/testing) +- Better Auth  [Better Auth](https://rivetkit.org/integrations/better-auth) +- AI SDK  [AI SDK](https://github.com/rivet-gg/rivetkit/issues/907) *(On The Roadmap)* -#### `ToolKit` +### Local-First Sync +- LiveStore  [LiveStore](https://github.com/rivet-gg/rivetkit/issues/908) *(Available In July)* +- ZeroSync  [ZeroSync](https://github.com/rivet-gg/rivetkit/issues/909) *(Help Wanted)* +- TinyBase  [TinyBase](https://github.com/rivet-gg/rivetkit/issues/910) *(Help Wanted)* +- Yjs  [Yjs](https://github.com/rivet-gg/rivetkit/issues/911) *(Help Wanted)* -```ts -interface ToolKit { - name: string; // lowercase alphanumeric + hyphens - description: string; - tools: Record; -} -``` +## Local Development with the Studio -#### `ToolExample` +Rivet Studio is like like Postman, but for all of your stateful serverless needs: -```ts -interface ToolExample { - description: string; - input: INPUT; -} -``` +- **Live State Inspection**: View and edit your actor state in real-time as messages are sent and processed +- **REPL**: Debug your actor in real-time - call actions, subscribe to events, and interact directly with your code +- **Connection Inspection**: Monitor active connections with state and parameters for each client +- **Hot Reload Code Changes**: See code changes instantly without restarting - modify and test on the fly -### Host Tools Helpers +[Visit the Studio →](https://studio.rivet.gg) -#### `hostTool(def)` +![Rivet Studio](.github/media/screenshots/studio/simple.png) -Creates a `HostTool` with type inference from the Zod schema: +## Community & Support -```ts -const myTool = hostTool({ - description: "...", - inputSchema: z.object({ name: z.string() }), - execute: ({ name }) => ({ greeting: `Hello ${name}` }), -}); -``` - -#### `toolKit(def)` - -Creates a `ToolKit`: - -```ts -const myToolkit = toolKit({ - name: "greetings", - description: "Greeting tools", - tools: { hello: myTool }, -}); -``` +Join thousands of developers building with RivetKit today: -## Documentation +- [Discord](https://rivet.gg/discord) - Chat with the community +- [X/Twitter](https://x.com/rivet_gg) - Follow for updates +- [Bluesky](https://bsky.app/profile/rivet.gg) - Follow for updates +- [GitHub Discussions](https://github.com/rivet-gg/rivetkit/discussions) - Ask questions and share ideas +- [GitHub Issues](https://github.com/rivet-gg/rivetkit/issues) - Report bugs and request features +- [Talk to an engineer](https://rivet.gg/talk-to-an-engineer) - Discuss your technical needs, current stack, and how Rivet can help with your infrastructure challenges -Full API reference and guides: [rivet.dev/docs/agent-os](https://rivet.dev/docs/agent-os/) +## License -## Development +[Apache 2.0](LICENSE) -```bash -pnpm install -pnpm build # turbo run build -pnpm test # turbo run test -pnpm check-types # turbo run check-types -pnpm lint # biome check -``` - -## License -Apache-2.0 diff --git a/biome.json b/biome.json index e55ef4727..719ba3e43 100644 --- a/biome.json +++ b/biome.json @@ -1,45 +1,32 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", - "files": { - "includes": [ - "packages/**/*.ts", - "examples/**/*.ts", - "!/**/node_modules" - ], - "ignoreUnknown": true - }, - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true, - "defaultBranch": "main" - }, - "formatter": { - "enabled": true, - "useEditorconfig": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "style": { - "noUselessElse": "off", - "useNodejsImportProtocol": "error" - }, - "correctness": { - "noUnusedImports": "warn" - }, - "suspicious": { - "noExplicitAny": "off", - "noControlCharactersInRegex": "off" - }, - "performance": { - "useTopLevelRegex": "off" - }, - "nursery": { - "noFloatingPromises": "error", - "noMisusedPromises": "error" - } - } - } + "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json", + "files": { + "includes": ["**/*.json", "**/*.ts", "**/*.js", "!examples/snippets/**"], + "ignoreUnknown": true + }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "defaultBranch": "main" + }, + "formatter": { + "enabled": true, + "useEditorconfig": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noUselessElse": "off" + }, + "correctness": { + "noUnusedImports": "warn" + }, + "suspicious": { + "noExplicitAny": "off" + } + } + } } diff --git a/clients/openapi/openapi.json b/clients/openapi/openapi.json new file mode 100644 index 000000000..e884b96d8 --- /dev/null +++ b/clients/openapi/openapi.json @@ -0,0 +1,679 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "0.9.5", + "title": "RivetKit 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-RivetKit-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": { + "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-RivetKit-Encoding", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Actor query information" + }, + "required": true, + "name": "X-RivetKit-Query", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection parameters" + }, + "required": false, + "name": "X-RivetKit-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-RivetKit-Encoding", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection parameters" + }, + "required": false, + "name": "X-RivetKit-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-RivetKit-Actor", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection ID", + "example": "conn-123456" + }, + "required": true, + "name": "X-RivetKit-Conn", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "The encoding format to use for the response (json, cbor)", + "example": "json" + }, + "required": true, + "name": "X-RivetKit-Encoding", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection token" + }, + "required": true, + "name": "X-RivetKit-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" + } + } + } + }, + "/actors/raw/http/*": { + "get": { + "parameters": [ + { + "schema": { + "type": "string", + "description": "Actor query information" + }, + "required": false, + "name": "X-RivetKit-Query", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection parameters" + }, + "required": false, + "name": "X-RivetKit-Conn-Params", + "in": "header" + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw request body (can be any content type)" + } + } + } + }, + "responses": { + "200": { + "description": "Success - response from actor's onFetch handler", + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw response from actor's onFetch handler" + } + } + } + }, + "404": { + "description": "Actor does not have an onFetch handler" + }, + "500": { + "description": "Internal server error or invalid response from actor" + } + } + }, + "post": { + "parameters": [ + { + "schema": { + "type": "string", + "description": "Actor query information" + }, + "required": false, + "name": "X-RivetKit-Query", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection parameters" + }, + "required": false, + "name": "X-RivetKit-Conn-Params", + "in": "header" + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw request body (can be any content type)" + } + } + } + }, + "responses": { + "200": { + "description": "Success - response from actor's onFetch handler", + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw response from actor's onFetch handler" + } + } + } + }, + "404": { + "description": "Actor does not have an onFetch handler" + }, + "500": { + "description": "Internal server error or invalid response from actor" + } + } + }, + "put": { + "parameters": [ + { + "schema": { + "type": "string", + "description": "Actor query information" + }, + "required": false, + "name": "X-RivetKit-Query", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection parameters" + }, + "required": false, + "name": "X-RivetKit-Conn-Params", + "in": "header" + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw request body (can be any content type)" + } + } + } + }, + "responses": { + "200": { + "description": "Success - response from actor's onFetch handler", + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw response from actor's onFetch handler" + } + } + } + }, + "404": { + "description": "Actor does not have an onFetch handler" + }, + "500": { + "description": "Internal server error or invalid response from actor" + } + } + }, + "delete": { + "parameters": [ + { + "schema": { + "type": "string", + "description": "Actor query information" + }, + "required": false, + "name": "X-RivetKit-Query", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection parameters" + }, + "required": false, + "name": "X-RivetKit-Conn-Params", + "in": "header" + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw request body (can be any content type)" + } + } + } + }, + "responses": { + "200": { + "description": "Success - response from actor's onFetch handler", + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw response from actor's onFetch handler" + } + } + } + }, + "404": { + "description": "Actor does not have an onFetch handler" + }, + "500": { + "description": "Internal server error or invalid response from actor" + } + } + }, + "patch": { + "parameters": [ + { + "schema": { + "type": "string", + "description": "Actor query information" + }, + "required": false, + "name": "X-RivetKit-Query", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection parameters" + }, + "required": false, + "name": "X-RivetKit-Conn-Params", + "in": "header" + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw request body (can be any content type)" + } + } + } + }, + "responses": { + "200": { + "description": "Success - response from actor's onFetch handler", + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw response from actor's onFetch handler" + } + } + } + }, + "404": { + "description": "Actor does not have an onFetch handler" + }, + "500": { + "description": "Internal server error or invalid response from actor" + } + } + }, + "head": { + "parameters": [ + { + "schema": { + "type": "string", + "description": "Actor query information" + }, + "required": false, + "name": "X-RivetKit-Query", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection parameters" + }, + "required": false, + "name": "X-RivetKit-Conn-Params", + "in": "header" + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw request body (can be any content type)" + } + } + } + }, + "responses": { + "200": { + "description": "Success - response from actor's onFetch handler", + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw response from actor's onFetch handler" + } + } + } + }, + "404": { + "description": "Actor does not have an onFetch handler" + }, + "500": { + "description": "Internal server error or invalid response from actor" + } + } + }, + "options": { + "parameters": [ + { + "schema": { + "type": "string", + "description": "Actor query information" + }, + "required": false, + "name": "X-RivetKit-Query", + "in": "header" + }, + { + "schema": { + "type": "string", + "description": "Connection parameters" + }, + "required": false, + "name": "X-RivetKit-Conn-Params", + "in": "header" + } + ], + "requestBody": { + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw request body (can be any content type)" + } + } + } + }, + "responses": { + "200": { + "description": "Success - response from actor's onFetch handler", + "content": { + "*/*": { + "schema": { + "nullable": true, + "description": "Raw response from actor's onFetch handler" + } + } + } + }, + "404": { + "description": "Actor does not have an onFetch handler" + }, + "500": { + "description": "Internal server error or invalid response from actor" + } + } + } + }, + "/actors/raw/websocket/*": { + "get": { + "responses": { + "101": { + "description": "WebSocket upgrade successful" + }, + "400": { + "description": "WebSockets not enabled or invalid request" + }, + "404": { + "description": "Actor does not have an onWebSocket handler" + } + } + } + } + } +} diff --git a/clients/python/.gitignore b/clients/python/.gitignore new file mode 100644 index 000000000..c8f044299 --- /dev/null +++ b/clients/python/.gitignore @@ -0,0 +1,72 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/clients/python/Cargo.toml b/clients/python/Cargo.toml new file mode 100644 index 000000000..675d09941 --- /dev/null +++ b/clients/python/Cargo.toml @@ -0,0 +1,23 @@ +# Python client code is generated by +# this package, with the aid of pyo3 +# +# This package turns into the python +# pypi rivetkit-client package +[package] +name = "python-rivetkit-client" +version = "0.9.0-rc.1" +edition = "2021" +publish = false + +[lib] +name = "rivetkit_client" +crate-type = ["cdylib"] + +[dependencies] +rivetkit-client = { path = "../rust/" } +futures-util = "0.3.31" +once_cell = "1.21.3" +pyo3 = { version = "0.24.0", features = ["extension-module"] } +pyo3-async-runtimes = { version = "0.24.0", features = ["tokio-runtime"] } +serde_json = "1.0.140" +tokio = "1.44.2" diff --git a/clients/python/README.md b/clients/python/README.md new file mode 100644 index 000000000..d20d0f40a --- /dev/null +++ b/clients/python/README.md @@ -0,0 +1,65 @@ +# RivetKit Python Client + +_The Python client for RivetKit, the Stateful Serverless Framework_ + +Use this client to connect to RivetKit services from Python applications. + +## Resources + +- [Quickstart](https://rivetkit.org/introduction) +- [Documentation](https://rivetkit.org/clients/python) +- [Examples](https://github.com/rivet-gg/rivetkit/tree/main/examples) + +## Getting Started + +### Step 1: Installation + +```bash +pip install python-rivetkit-client +``` + +### Step 2: Connect to Actor + +```python +from python_rivetkit_client import AsyncClient as ActorClient +import asyncio + +async def main(): + # Create a client connected to your RivetKit manager + client = ActorClient("http://localhost:8080") + + # Connect to a chat room actor + chat_room = await client.get("chat-room", tags=[("room", "general")]) + + # Listen for new messages + chat_room.on_event("newMessage", lambda msg: print(f"New message: {msg}")) + + # Send message to room + await chat_room.action("sendMessage", ["alice", "Hello, World!"]) + + # When finished + await chat_room.disconnect() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Features + +- Async-first design with `AsyncClient` +- Event subscription support via `on_event` +- Action invocation with JSON-serializable arguments +- Simple event handling with `receive` method +- Clean disconnection handling via `disconnect` + +## Community & Support + +- Join our [Discord](https://rivet.gg/discord) +- Follow us on [X](https://x.com/rivet_gg) +- Follow us on [Bluesky](https://bsky.app/profile/rivet.gg) +- File bug reports in [GitHub Issues](https://github.com/rivet-gg/rivetkit/issues) +- Post questions & ideas in [GitHub Discussions](https://github.com/rivet-gg/rivetkit/discussions) + +## License + +Apache 2.0 diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml new file mode 100644 index 000000000..911c74167 --- /dev/null +++ b/clients/python/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "rivetkit-client" +version = "0.9.0-rc.1" +authors = [ + { name="Rivet Gaming, LLC", email="developer@rivet.gg" }, +] +description = "Python client for RivetKit - the Stateful Serverless Framework for building AI agents, realtime apps, and game servers" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [] + +[project.urls] +Homepage = "https://github.com/rivet-gg/rivetkit" +Issues = "https://github.com/rivet-gg/rivetkit/issues" + +[project.optional-dependencies] +tests = [ + "asyncio==3.4.3", + "iniconfig==2.1.0", + "packaging==24.2", + "pluggy==1.5.0", + "pytest==8.3.5", + "pytest-aio==1.9.0", +] + + +[build-system] +requires = ["maturin>=1.8,<2.0"] +build-backend = "maturin" + +[tool.maturin] +features = ["pyo3/extension-module"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +log_cli = true +log_level = "DEBUG" diff --git a/clients/python/requirements.txt b/clients/python/requirements.txt new file mode 100644 index 000000000..862d7842d --- /dev/null +++ b/clients/python/requirements.txt @@ -0,0 +1,10 @@ +asyncio==3.4.3 +iniconfig==2.1.0 +maturin==1.8.3 +packaging==24.2 +pluggy==1.5.0 +pytest==8.3.5 +pytest-asyncio==0.26.0 + +# python -m venv venv +# source venv/bin/activate \ No newline at end of file diff --git a/clients/python/src/events/async/client.rs b/clients/python/src/events/async/client.rs new file mode 100644 index 000000000..ca3db7c69 --- /dev/null +++ b/clients/python/src/events/async/client.rs @@ -0,0 +1,129 @@ +use std::sync::Arc; + +use rivetkit_client::{self as rivetkit_rs, CreateOptions, GetOptions, GetWithIdOptions}; +use pyo3::prelude::*; + +use crate::util::{try_opts_from_kwds, PyKwdArgs}; + +use super::handle::ActorHandle; + +#[pyclass(name = "AsyncClient")] +pub struct Client { + client: Arc, +} + +#[pymethods] +impl Client { + #[new] + #[pyo3(signature=( + endpoint, + transport_kind="websocket", + encoding_kind="json" + ))] + fn py_new( + endpoint: &str, + transport_kind: &str, + encoding_kind: &str, + ) -> PyResult { + let transport_kind = try_transport_kind_from_str(&transport_kind)?; + let encoding_kind = try_encoding_kind_from_str(&encoding_kind)?; + let client = rivetkit_rs::Client::new( + endpoint.to_string(), + transport_kind, + encoding_kind + ); + + Ok(Client { + client: Arc::new(client) + }) + } + + #[pyo3(signature = (name, **kwds))] + fn get<'a>(&self, py: Python<'a>, name: &str, kwds: Option) -> PyResult> { + let opts = try_opts_from_kwds::(kwds)?; + let name = name.to_string(); + let client = self.client.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let handle = client.get(&name, opts).await; + + match handle { + Ok(handle) => Ok(ActorHandle { + handle + }), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + }) + } + + #[pyo3(signature = (id, **kwds))] + fn get_with_id<'a>(&self, py: Python<'a>, id: &str, kwds: Option) -> PyResult> { + let opts = try_opts_from_kwds::(kwds)?; + let id = id.to_string(); + let client = self.client.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let handle = client.get_with_id(&id, opts).await; + + match handle { + Ok(handle) => Ok(ActorHandle { + handle + }), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + }) + } + + #[pyo3(signature = (name, **kwds))] + fn create<'a>(&self, py: Python<'a>, name: &str, kwds: Option) -> PyResult> { + let opts = try_opts_from_kwds::(kwds)?; + let name = name.to_string(); + let client = self.client.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let handle = client.create(&name, opts).await; + + match handle { + Ok(handle) => Ok(ActorHandle { + handle + }), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + }) + } +} + +fn try_transport_kind_from_str( + transport_kind: &str +) -> PyResult { + match transport_kind { + "websocket" => Ok(rivetkit_rs::TransportKind::WebSocket), + "sse" => Ok(rivetkit_rs::TransportKind::Sse), + _ => Err(py_value_err!( + "Invalid transport kind: {}", + transport_kind + )), + } +} + +fn try_encoding_kind_from_str( + encoding_kind: &str +) -> PyResult { + match encoding_kind { + "json" => Ok(rivetkit_rs::EncodingKind::Json), + "cbor" => Ok(rivetkit_rs::EncodingKind::Cbor), + _ => Err(py_value_err!( + "Invalid encoding kind: {}", + encoding_kind + )), + } +} diff --git a/clients/python/src/events/async/handle.rs b/clients/python/src/events/async/handle.rs new file mode 100644 index 000000000..9069e5d85 --- /dev/null +++ b/clients/python/src/events/async/handle.rs @@ -0,0 +1,95 @@ + +use rivetkit_client::{self as rivetkit_rs}; +use pyo3::{prelude::*, types::PyTuple}; + +use crate::util; + +#[pyclass] +pub struct ActorHandle { + pub handle: rivetkit_rs::connection::ActorHandle, +} + +#[pymethods] +impl ActorHandle { + #[new] + pub fn new() -> PyResult { + Err(py_runtime_err!( + "Actor handle cannot be instantiated directly", + )) + } + + pub fn action<'a>( + &self, + py: Python<'a>, + method: &str, + args: Vec + ) -> PyResult> { + let method = method.to_string(); + let handle = self.handle.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let args = Python::with_gil(|py| util::py_to_json_value(py, &args))?; + let result = handle.action(&method, args).await; + let Ok(result) = result else { + return Err(py_runtime_err!( + "Failed to call action: {:?}", + result.err() + )); + }; + let mut result = Python::with_gil(|py| { + match util::json_to_py_value(py, &vec![result]) { + Ok(value) => Ok( + value.iter() + .map(|x| x.clone().unbind()) + .collect::>() + ), + Err(e) => Err(e), + } + })?; + let Some(result) = result.drain(0..1).next() else { + return Err(py_runtime_err!( + "Expected one result, got {}", + result.len() + )); + }; + + Ok(result) + }) + } + + pub fn on_event<'a>( + &self, + py: Python<'a>, + event_name: &str, + callback: PyObject + ) -> PyResult> { + let event_name = event_name.to_string(); + let handle = self.handle.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + handle.on_event(&event_name, move |args| { + if let Err(e) = Python::with_gil(|py| -> PyResult<()> { + let args = util::json_to_py_value(py, args)?; + let args = PyTuple::new(py, args)?; + + callback.call(py, args, None)?; + + Ok(()) + }) { + eprintln!("Failed to call event callback: {}", e); + } + }).await; + + Ok(()) + }) + } + + pub fn disconnect<'a>(&self, py: Python<'a>) -> PyResult> { + let handle = self.handle.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + handle.disconnect().await; + + Ok(()) + }) + } +} diff --git a/clients/python/src/events/async/mod.rs b/clients/python/src/events/async/mod.rs new file mode 100644 index 000000000..97a804cae --- /dev/null +++ b/clients/python/src/events/async/mod.rs @@ -0,0 +1,11 @@ +use pyo3::prelude::*; + +mod handle; +mod client; + +pub fn init_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + + + Ok(()) +} \ No newline at end of file diff --git a/clients/python/src/events/mod.rs b/clients/python/src/events/mod.rs new file mode 100644 index 000000000..a5d945c3d --- /dev/null +++ b/clients/python/src/events/mod.rs @@ -0,0 +1,12 @@ +use pyo3::prelude::*; + +mod sync; +mod r#async; + +pub fn init_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + sync::init_module(m)?; + r#async::init_module(m)?; + + + Ok(()) +} \ No newline at end of file diff --git a/clients/python/src/events/sync/client.rs b/clients/python/src/events/sync/client.rs new file mode 100644 index 000000000..b18f592b0 --- /dev/null +++ b/clients/python/src/events/sync/client.rs @@ -0,0 +1,108 @@ +use rivetkit_client::{self as rivetkit_rs, CreateOptions, GetOptions, GetWithIdOptions}; +use pyo3::prelude::*; + +use super::handle::ActorHandle; +use crate::util::{try_opts_from_kwds, PyKwdArgs, SYNC_RUNTIME}; + +#[pyclass(name = "Client")] +pub struct Client { + client: rivetkit_rs::Client, +} + +#[pymethods] +impl Client { + #[new] + #[pyo3(signature=( + endpoint, + transport_kind="websocket", + encoding_kind="json" + ))] + fn py_new( + endpoint: &str, + transport_kind: &str, + encoding_kind: &str, + ) -> PyResult { + let transport_kind = try_transport_kind_from_str(&transport_kind)?; + let encoding_kind = try_encoding_kind_from_str(&encoding_kind)?; + let client = rivetkit_rs::Client::new( + endpoint.to_string(), + transport_kind, + encoding_kind + ); + + Ok(Client { + client + }) + } + + #[pyo3(signature = (name, **kwds))] + fn get(&self, name: &str, kwds: Option) -> PyResult { + let opts = try_opts_from_kwds::(kwds)?; + let handle = self.client.get(name, opts); + let handle = SYNC_RUNTIME.block_on(handle); + + match handle { + Ok(handle) => Ok(ActorHandle { handle }), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + } + + #[pyo3(signature = (id, **kwds))] + fn get_with_id(&self, id: &str, kwds: Option) -> PyResult { + let opts = try_opts_from_kwds::(kwds)?; + let handle = self.client.get_with_id(id, opts); + let handle = SYNC_RUNTIME.block_on(handle); + + match handle { + Ok(handle) => Ok(ActorHandle { handle }), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + } + + #[pyo3(signature = (name, **kwds))] + fn create(&self, name: &str, kwds: Option) -> PyResult { + let opts = try_opts_from_kwds::(kwds)?; + let handle = self.client.create(name, opts); + let handle = SYNC_RUNTIME.block_on(handle); + + match handle { + Ok(handle) => Ok(ActorHandle { handle }), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + } +} + +fn try_transport_kind_from_str( + transport_kind: &str +) -> PyResult { + match transport_kind { + "websocket" => Ok(rivetkit_rs::TransportKind::WebSocket), + "sse" => Ok(rivetkit_rs::TransportKind::Sse), + _ => Err(py_value_err!( + "Invalid transport kind: {}", + transport_kind + )), + } +} + +fn try_encoding_kind_from_str( + encoding_kind: &str +) -> PyResult { + match encoding_kind { + "json" => Ok(rivetkit_rs::EncodingKind::Json), + "cbor" => Ok(rivetkit_rs::EncodingKind::Cbor), + _ => Err(py_value_err!( + "Invalid encoding kind: {}", + encoding_kind + )), + } +} diff --git a/clients/python/src/events/sync/handle.rs b/clients/python/src/events/sync/handle.rs new file mode 100644 index 000000000..1c239449a --- /dev/null +++ b/clients/python/src/events/sync/handle.rs @@ -0,0 +1,69 @@ +use rivetkit_client::{self as rivetkit_rs}; +use pyo3::{prelude::*, types::PyTuple}; + +use crate::util::{self, SYNC_RUNTIME}; + +#[pyclass] +pub struct ActorHandle { + pub handle: rivetkit_rs::connection::ActorHandle, +} + +#[pymethods] +impl ActorHandle { + #[new] + pub fn new() -> PyResult { + Err(py_runtime_err!( + "Actor handle cannot be instantiated directly", + )) + } + + pub fn action<'a>( + &self, + py: Python<'a>, + method: &str, + args: Vec + ) -> PyResult> { + let result = self.handle.action( + method, + util::py_to_json_value(py, &args)? + ); + let result = SYNC_RUNTIME.block_on(result); + let Ok(result) = result else { + return Err(py_runtime_err!( + "Failed to call action: {:?}", + result.err() + )); + }; + let mut result = util::json_to_py_value(py, &vec![result])?; + let Some(result) = result.drain(0..1).next() else { + return Err(py_runtime_err!( + "Expected one result, got {}", + result.len() + )); + }; + Ok(result) + } + + pub fn on_event(&self, event_name: &str, callback: PyObject) { + SYNC_RUNTIME.block_on( + self.handle.on_event(event_name, move |args| { + if let Err(e) = Python::with_gil(|py| -> PyResult<()> { + let args = util::json_to_py_value(py, args)?; + let args = PyTuple::new(py, args)?; + + callback.call(py, args, None)?; + + Ok(()) + }) { + eprintln!("Failed to call event callback: {}", e); + } + }) + ); + } + + pub fn disconnect(&self) { + SYNC_RUNTIME.block_on( + self.handle.disconnect() + ) + } +} diff --git a/clients/python/src/events/sync/mod.rs b/clients/python/src/events/sync/mod.rs new file mode 100644 index 000000000..97a804cae --- /dev/null +++ b/clients/python/src/events/sync/mod.rs @@ -0,0 +1,11 @@ +use pyo3::prelude::*; + +mod handle; +mod client; + +pub fn init_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + + + Ok(()) +} \ No newline at end of file diff --git a/clients/python/src/lib.rs b/clients/python/src/lib.rs new file mode 100644 index 000000000..9072d08e4 --- /dev/null +++ b/clients/python/src/lib.rs @@ -0,0 +1,15 @@ +use pyo3::prelude::*; + +#[macro_use] +mod util; +mod simple; +mod events; + +#[pymodule] +fn rivetkit_client(m: &Bound<'_, PyModule>) -> PyResult<()> { + simple::init_module(m)?; + events::init_module(m)?; + + + Ok(()) +} diff --git a/clients/python/src/simple/async/client.rs b/clients/python/src/simple/async/client.rs new file mode 100644 index 000000000..2117955ad --- /dev/null +++ b/clients/python/src/simple/async/client.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use rivetkit_client::{self as rivetkit_rs, CreateOptions, GetOptions, GetWithIdOptions}; +use pyo3::prelude::*; + +use crate::util::{try_opts_from_kwds, PyKwdArgs}; + +use super::handle::ActorHandle; + +#[pyclass(name = "AsyncSimpleClient")] +pub struct Client { + client: Arc, +} + +#[pymethods] +impl Client { + #[new] + #[pyo3(signature=( + endpoint, + transport_kind="websocket", + encoding_kind="json" + ))] + fn py_new( + endpoint: &str, + transport_kind: &str, + encoding_kind: &str, + ) -> PyResult { + let transport_kind = try_transport_kind_from_str(&transport_kind)?; + let encoding_kind = try_encoding_kind_from_str(&encoding_kind)?; + let client = rivetkit_rs::Client::new( + endpoint.to_string(), + transport_kind, + encoding_kind + ); + + Ok(Client { + client: Arc::new(client) + }) + } + + #[pyo3(signature = (name, **kwds))] + fn get<'a>(&self, py: Python<'a>, name: &str, kwds: Option) -> PyResult> { + let opts = try_opts_from_kwds::(kwds)?; + let name = name.to_string(); + let client = self.client.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let handle = client.get(&name, opts).await; + + match handle { + Ok(handle) => Ok(ActorHandle::new(handle)), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + }) + } + + #[pyo3(signature = (id, **kwds))] + fn get_with_id<'a>(&self, py: Python<'a>, id: &str, kwds: Option) -> PyResult> { + let opts = try_opts_from_kwds::(kwds)?; + let id = id.to_string(); + let client = self.client.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let handle = client.get_with_id(&id, opts).await; + + match handle { + Ok(handle) => Ok(ActorHandle::new(handle)), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + }) + } + + #[pyo3(signature = (name, **kwds))] + fn create<'a>(&self, py: Python<'a>, name: &str, kwds: Option) -> PyResult> { + let opts = try_opts_from_kwds::(kwds)?; + let name = name.to_string(); + let client = self.client.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let handle = client.create(&name, opts).await; + + match handle { + Ok(handle) => Ok(ActorHandle::new(handle)), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + }) + } +} + +fn try_transport_kind_from_str( + transport_kind: &str +) -> PyResult { + match transport_kind { + "websocket" => Ok(rivetkit_rs::TransportKind::WebSocket), + "sse" => Ok(rivetkit_rs::TransportKind::Sse), + _ => Err(py_value_err!( + "Invalid transport kind: {}", + transport_kind + )), + } +} + +fn try_encoding_kind_from_str( + encoding_kind: &str +) -> PyResult { + match encoding_kind { + "json" => Ok(rivetkit_rs::EncodingKind::Json), + "cbor" => Ok(rivetkit_rs::EncodingKind::Cbor), + _ => Err(py_value_err!( + "Invalid encoding kind: {}", + encoding_kind + )), + } +} diff --git a/clients/python/src/simple/async/handle.rs b/clients/python/src/simple/async/handle.rs new file mode 100644 index 000000000..2daae6fc5 --- /dev/null +++ b/clients/python/src/simple/async/handle.rs @@ -0,0 +1,187 @@ +use rivetkit_client::{self as rivetkit_rs}; +use futures_util::FutureExt; +use pyo3::{prelude::*, types::{PyList, PyString, PyTuple}}; +use tokio::sync::mpsc; + +use crate::util; + +const EVENT_BUFFER_SIZE: usize = 100; + +struct ActorEvent { + name: String, + args: Vec, +} + +#[pyclass] +pub struct ActorHandle { + handle: rivetkit_rs::connection::ActorHandle, + event_rx: Option>, + event_tx: mpsc::Sender, +} + +impl ActorHandle { + pub fn new(handle: rivetkit_rs::connection::ActorHandle) -> Self { + let (event_tx, event_rx) = mpsc::channel(EVENT_BUFFER_SIZE); + + Self { + handle, + event_tx, + event_rx: Some(event_rx), + } + } +} + +#[pymethods] +impl ActorHandle { + #[new] + pub fn py_new() -> PyResult { + Err(py_runtime_err!( + "Actor handle cannot be instantiated directly", + )) + } + + pub fn action<'a>( + &self, + py: Python<'a>, + method: &str, + args: Vec + ) -> PyResult> { + let method = method.to_string(); + let handle = self.handle.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let args = Python::with_gil(|py| util::py_to_json_value(py, &args))?; + let result = handle.action(&method, args).await; + let Ok(result) = result else { + return Err(py_runtime_err!( + "Failed to call action: {:?}", + result.err() + )); + }; + let mut result = Python::with_gil(|py| { + match util::json_to_py_value(py, &vec![result]) { + Ok(value) => Ok( + value.iter() + .map(|x| x.clone().unbind()) + .collect::>() + ), + Err(e) => Err(e), + } + })?; + let Some(result) = result.drain(0..1).next() else { + return Err(py_runtime_err!( + "Expected one result, got {}", + result.len() + )); + }; + + Ok(result) + }) + } + + pub fn subscribe<'a>( + &self, + py: Python<'a>, + event_name: &str + ) -> PyResult> { + let event_name = event_name.to_string(); + let handle = self.handle.clone(); + let tx = self.event_tx.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + handle.on_event(&event_name.clone(), move |args| { + let event_name = event_name.clone(); + let args = args.clone(); + let tx = tx.clone(); + + tokio::spawn(async move { + let event = ActorEvent { + name: event_name, + args: args.clone(), + }; + // Send this upstream(?) + tx.send(event).await.map_err(|e| { + py_runtime_err!( + "Failed to send via inner tx: {}", + e + ) + }).ok(); + }); + }).await; + + Ok(()) + }) + } + + #[pyo3(signature=(count, timeout=None))] + pub fn receive<'a>( + &mut self, + py: Python<'a>, + count: u32, + timeout: Option + ) -> PyResult> { + let mut rx = self.event_rx.take().ok_or_else(|| { + py_runtime_err!("Two .receive() calls cannot co-exist") + })?; + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let result: Vec = { + let mut events: Vec = Vec::new(); + + loop { + if events.len() >= count as usize { + break; + } + + let timeout_rx_future = match timeout { + Some(timeout) => { + let timeout = std::time::Duration::from_secs_f64(timeout); + tokio::time::timeout(timeout, rx.recv()) + .map(|x| x.unwrap_or(None)).boxed() + }, + None => rx.recv().boxed() + }; + + tokio::select! { + result = timeout_rx_future => { + match result { + Some(event) => events.push(event), + None => break, + } + }, + // TODO: Add more signal support + _ = tokio::signal::ctrl_c() => { + Python::with_gil(|py| py.check_signals())?; + } + }; + } + + Ok::<_, PyErr>(events) + }?; + + // Convert events to Python objects + Python::with_gil(|py| { + let py_events = PyList::empty(py); + for event in result { + let event = PyTuple::new(py, &[ + PyString::new(py, &event.name).as_any(), + PyList::new(py, &util::json_to_py_value(py, &event.args)?)?.as_any(), + ])?; + py_events.append(event)?; + } + + Ok(py_events.unbind()) + }) + }) + } + + pub fn disconnect<'a>(&self, py: Python<'a>) -> PyResult> { + let handle = self.handle.clone(); + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + handle.disconnect().await; + + Ok(()) + }) + } +} diff --git a/clients/python/src/simple/async/mod.rs b/clients/python/src/simple/async/mod.rs new file mode 100644 index 000000000..97a804cae --- /dev/null +++ b/clients/python/src/simple/async/mod.rs @@ -0,0 +1,11 @@ +use pyo3::prelude::*; + +mod handle; +mod client; + +pub fn init_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + + + Ok(()) +} \ No newline at end of file diff --git a/clients/python/src/simple/mod.rs b/clients/python/src/simple/mod.rs new file mode 100644 index 000000000..a5d945c3d --- /dev/null +++ b/clients/python/src/simple/mod.rs @@ -0,0 +1,12 @@ +use pyo3::prelude::*; + +mod sync; +mod r#async; + +pub fn init_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + sync::init_module(m)?; + r#async::init_module(m)?; + + + Ok(()) +} \ No newline at end of file diff --git a/clients/python/src/simple/sync/client.rs b/clients/python/src/simple/sync/client.rs new file mode 100644 index 000000000..8b540f1ba --- /dev/null +++ b/clients/python/src/simple/sync/client.rs @@ -0,0 +1,109 @@ +use rivetkit_client::{self as rivetkit_rs, CreateOptions, GetOptions, GetWithIdOptions}; +use pyo3::prelude::*; + +use super::handle::ActorHandle; +use crate::util::{try_opts_from_kwds, PyKwdArgs, SYNC_RUNTIME}; + +#[pyclass(name = "SimpleClient")] +pub struct Client { + client: rivetkit_rs::Client, +} + +#[pymethods] +impl Client { + #[new] + #[pyo3(signature=( + endpoint, + transport_kind="websocket", + encoding_kind="json" + ))] + fn py_new( + endpoint: &str, + transport_kind: &str, + encoding_kind: &str, + ) -> PyResult { + let transport_kind = try_transport_kind_from_str(&transport_kind)?; + let encoding_kind = try_encoding_kind_from_str(&encoding_kind)?; + let client = rivetkit_rs::Client::new( + endpoint.to_string(), + transport_kind, + encoding_kind + ); + + Ok(Client { + client + }) + } + + #[pyo3(signature = (name, **kwds))] + fn get(&self, name: &str, kwds: Option) -> PyResult { + let opts = try_opts_from_kwds::(kwds)?; + + let handle = self.client.get(name, opts); + let handle = SYNC_RUNTIME.block_on(handle); + + match handle { + Ok(handle) => Ok(ActorHandle::new(handle)), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + } + + #[pyo3(signature = (id, **kwds))] + fn get_with_id(&self, id: &str, kwds: Option) -> PyResult { + let opts = try_opts_from_kwds::(kwds)?; + let handle = self.client.get_with_id(id, opts); + let handle = SYNC_RUNTIME.block_on(handle); + + match handle { + Ok(handle) => Ok(ActorHandle::new(handle)), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + } + + #[pyo3(signature = (name, **kwds))] + fn create(&self, name: &str, kwds: Option) -> PyResult { + let opts = try_opts_from_kwds::(kwds)?; + let handle = self.client.create(name, opts); + let handle = SYNC_RUNTIME.block_on(handle); + + match handle { + Ok(handle) => Ok(ActorHandle::new(handle)), + Err(e) => Err(py_runtime_err!( + "Failed to get actor: {}", + e + )) + } + } +} + +fn try_transport_kind_from_str( + transport_kind: &str +) -> PyResult { + match transport_kind { + "websocket" => Ok(rivetkit_rs::TransportKind::WebSocket), + "sse" => Ok(rivetkit_rs::TransportKind::Sse), + _ => Err(py_value_err!( + "Invalid transport kind: {}", + transport_kind + )), + } +} + +fn try_encoding_kind_from_str( + encoding_kind: &str +) -> PyResult { + match encoding_kind { + "json" => Ok(rivetkit_rs::EncodingKind::Json), + "cbor" => Ok(rivetkit_rs::EncodingKind::Cbor), + _ => Err(py_value_err!( + "Invalid encoding kind: {}", + encoding_kind + )), + } +} diff --git a/clients/python/src/simple/sync/handle.rs b/clients/python/src/simple/sync/handle.rs new file mode 100644 index 000000000..c75c5fe77 --- /dev/null +++ b/clients/python/src/simple/sync/handle.rs @@ -0,0 +1,169 @@ +use rivetkit_client::{self as rivetkit_rs}; +use futures_util::FutureExt; +use pyo3::{prelude::*, types::{PyList, PyString, PyTuple}}; +use tokio::sync::mpsc; + +use crate::util::{self, SYNC_RUNTIME}; + +const EVENT_BUFFER_SIZE: usize = 100; + +struct ActorEvent { + name: String, + args: Vec, +} + +#[pyclass] +pub struct ActorHandle { + handle: rivetkit_rs::connection::ActorHandle, + event_rx: Option>, + event_tx: mpsc::Sender, +} + +impl ActorHandle { + pub fn new(handle: rivetkit_rs::connection::ActorHandle) -> Self { + let (event_tx, event_rx) = mpsc::channel(EVENT_BUFFER_SIZE); + + Self { + handle, + event_tx, + event_rx: Some(event_rx), + } + } +} + +#[pymethods] +impl ActorHandle { + #[new] + pub fn py_new() -> PyResult { + Err(py_runtime_err!("Actor handle cannot be instantiated directly")) + } + + #[pyo3(signature=(method, *py_args))] + pub fn action<'a>( + &self, + py: Python<'a>, + method: &str, + py_args: &Bound<'_, PyTuple>, + ) -> PyResult> { + let args = py_args.extract::>()?; + + let result = self.handle.action( + method, + util::py_to_json_value(py, &args)? + ); + let result = SYNC_RUNTIME.block_on(result); + + let Ok(result) = result else { + return Err(py_runtime_err!( + "Failed to call action: {:?}", + result.err() + )); + }; + + let mut result = util::json_to_py_value(py, &vec![result])?; + let Some(result) = result.drain(0..1).next() else { + return Err(py_runtime_err!( + "Expected one result, got {}", + result.len() + )); + }; + + Ok(result) + } + + pub fn subscribe( + &self, + event_name: &str, + ) -> PyResult<()> { + let event_name = event_name.to_string(); + let tx = self.event_tx.clone(); + + SYNC_RUNTIME.block_on( + self.handle.on_event(&event_name.clone(), move |args| { + let event_name = event_name.clone(); + let args = args.clone(); + let tx = tx.clone(); + + tokio::spawn(async move { + let event = ActorEvent { + name: event_name, + args: args.clone(), + }; + // Send this upstream(?) + tx.send(event).await.map_err(|e| { + py_runtime_err!( + "Failed to send via inner tx: {}", + e + ) + }).ok(); + }); + }) + ); + + Ok(()) + } + + #[pyo3(signature=(count, timeout=None))] + pub fn receive<'a>( + &mut self, + py: Python<'a>, + count: u32, + timeout: Option + ) -> PyResult> { + let mut rx = self.event_rx.take().ok_or_else(|| { + py_runtime_err!("Two .receive() calls cannot co-exist") + })?; + + let result: Vec = SYNC_RUNTIME.block_on(async { + let mut events: Vec = Vec::new(); + + loop { + if events.len() >= count as usize { + break; + } + + let timeout_rx_future = match timeout { + Some(timeout) => { + let timeout = std::time::Duration::from_secs_f64(timeout); + tokio::time::timeout(timeout, rx.recv()) + .map(|x| x.unwrap_or(None)).boxed() + }, + None => rx.recv().boxed() + }; + + tokio::select! { + result = timeout_rx_future => { + match result { + Some(event) => events.push(event), + None => break, + } + }, + // TODO: Add more signal support + _ = tokio::signal::ctrl_c() => { + py.check_signals()?; + } + }; + } + + Ok::<_, PyErr>(events) + })?; + + // Convert events to Python objects + let py_events = PyList::empty(py); + for event in result { + let event = PyTuple::new(py, &[ + PyString::new(py, &event.name).as_any(), + PyList::new(py, &util::json_to_py_value(py, &event.args)?)?.as_any(), + ])?; + py_events.append(event)?; + } + + Ok(py_events) + } + + pub fn disconnect(&self) { + SYNC_RUNTIME.block_on( + self.handle.disconnect() + ) + } +} diff --git a/clients/python/src/simple/sync/mod.rs b/clients/python/src/simple/sync/mod.rs new file mode 100644 index 000000000..97a804cae --- /dev/null +++ b/clients/python/src/simple/sync/mod.rs @@ -0,0 +1,11 @@ +use pyo3::prelude::*; + +mod handle; +mod client; + +pub fn init_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + + + Ok(()) +} \ No newline at end of file diff --git a/clients/python/src/util.rs b/clients/python/src/util.rs new file mode 100644 index 000000000..42f75b8c6 --- /dev/null +++ b/clients/python/src/util.rs @@ -0,0 +1,262 @@ +use rivetkit_client::{ + client, CreateOptions, GetOptions, GetWithIdOptions +}; +use once_cell::sync::Lazy; +use pyo3::{prelude::*, types::PyDict}; +use tokio::runtime::{self, Runtime}; + +pub static SYNC_RUNTIME: Lazy = Lazy::new(|| { + runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .unwrap() +}); + +macro_rules! py_runtime_err { + ($msg:expr) => { + pyo3::exceptions::PyRuntimeError::new_err($msg) + }; + ($msg:expr, $($arg:tt)*) => { + pyo3::exceptions::PyRuntimeError::new_err(format!( + $msg, + $($arg)* + )) + }; +} + +macro_rules! py_value_err { + ($msg:expr) => { + pyo3::exceptions::PyValueError::new_err($msg) + }; + ($msg:expr, $($arg:tt)*) => { + pyo3::exceptions::PyValueError::new_err(format!( + $msg, + $($arg)* + )) + }; +} + +// See ACTR-96 +pub fn py_to_json_value( + py: Python<'_>, + py_obj: &Vec +) -> PyResult> { + let py_json = py.import("json")?; + + let obj_strs: Vec = py_obj + .into_iter() + .map(|obj| { + let obj_str: String = py_json + .call_method("dumps", (obj,), None)? + .extract::()?; + + Ok(obj_str) + }) + .collect::>>()?; + + let json_value = obj_strs + .into_iter() + .map(|s| { + match serde_json::from_str(&s) { + Ok(value) => Ok(value), + Err(e) => Err(py_value_err!( + "Failed to parse JSON: {}", + e + )) + } + }) + .collect::>>()?; + + Ok(json_value) +} + +pub fn json_to_py_value<'a>( + py: Python<'a>, + val: &Vec +) -> PyResult>> { + let py_json = py.import("json")?; + + val.into_iter() + .map(|f| { + let str = serde_json::to_string(f) + .map_err(|e| py_value_err!( + "Failed to serialize JSON value: {}", + e + ))?; + + py_json + .call_method("loads", (str,), None) + .map_err(|e| py_value_err!( + "Failed to load JSON value: {}", + e + )) + }) + .collect() +} + +fn extract_tags(tags: Option>) -> PyResult>> { + let Some(tags) = tags else { + return Ok(None) + }; + + // tags should be a Dict + // Convert it to a Vec<(String, String)> + let tags_dict = tags.downcast::().map_err(|_| { + py_value_err!( + "Invalid tags format. Expected a dictionary with both string keys and values" + ) + })?; + let tags = tags_dict + .iter() + .map(|(key, value)| { + let key: String = key.extract()?; + let value: String = value.extract()?; + Ok((key, value)) + }) + .collect::>>() + .map_err(|_| { + py_value_err!( + "Invalid tags format. Expected a dictionary with both string keys and values" + ) + })?; + + Ok(Some(tags)) +} + +fn extract_params(params: Option>) -> PyResult> { + let Some(params) = params else { + return Ok(None) + }; + + let value = Python::with_gil(|py| py_to_json_value(py, &vec![params.unbind()]))?; + let Some(value) = value.first() else { + return Err(py_runtime_err!("Failed to convert params to JSON value")); + }; + + Ok(Some(value.clone())) +} + + +pub type PyKwdArgs<'a> = Bound<'a, PyDict>; +pub struct PyKwdArgsWrapper<'a>(pub PyKwdArgs<'a>); +pub fn try_opts_from_kwds<'a, T>(kwds: Option>) -> PyResult +where + T: TryFrom, Error = PyErr> + Default, +{ + let opts = kwds.map_or(Ok(T::default()), |kwds| { + T::try_from(PyKwdArgsWrapper(kwds)) + })?; + + Ok(opts) +} + +impl TryFrom::> for GetOptions { + type Error = PyErr; + + fn try_from(kwds: PyKwdArgsWrapper) -> PyResult { + let tags = extract_tags(kwds.0.get_item("tags")?)?; + + let params = match kwds.0.get_item("params")? { + Some(params) => { + let value = Python::with_gil(|py| py_to_json_value(py, &vec![params.unbind()]))?; + let Some(value) = value.first() else { + return Err(py_runtime_err!("Failed to convert params to JSON value")); + }; + + Some(value.clone()) + }, + None => None + }; + + let no_create = match kwds.0.get_item("no_create")? { + Some(no_create) => { + Some(no_create.extract::().map_err(|_| { + py_value_err!( + "Invalid no_create format. Expected a boolean" + ) + })?) + }, + None => None + }; + + let create = match kwds.0.get_item("create")? { + Some(create) => { + let create_req_metadata = create.downcast::().map_err(|_| { + py_value_err!( + "Invalid create format. Expected a dictionary" + ) + })?; + + let tags = extract_tags(create_req_metadata.get_item("tags")?)?; + + let region = create_req_metadata + .get_item("region")? + .map(|v| v.extract::()) + .transpose()?; + + Some(client::PartialCreateRequestMetadata { + tags, + region + }) + }, + None => None + }; + + Ok(GetOptions { + tags, + params, + no_create, + create + }) + } +} + +impl TryFrom::> for CreateOptions { + type Error = PyErr; + + fn try_from(kwds: PyKwdArgsWrapper) -> PyResult { + let params = extract_params(kwds.0.get_item("params")?)?; + + let create = match kwds.0.get_item("create")? { + Some(create) => { + let create_req_metadata = create.downcast::().map_err(|_| { + py_value_err!( + "Invalid create format. Expected a dictionary" + ) + })?; + + let tags = extract_tags(create_req_metadata.get_item("tags")?)? + .unwrap_or_default(); + + let region = create_req_metadata + .get_item("region")? + .map(|v| v.extract::()) + .transpose()?; + + client::CreateRequestMetadata { + tags, + region + } + }, + None => client::CreateRequestMetadata::default() + }; + + Ok(CreateOptions { + params, + create, + }) + } +} + +impl TryFrom::> for GetWithIdOptions { + type Error = PyErr; + + fn try_from(kwds: PyKwdArgsWrapper) -> PyResult { + let params = extract_params(kwds.0.get_item("params")?)?; + + Ok(GetWithIdOptions { + params + }) + } +} diff --git a/clients/python/tests/common.py b/clients/python/tests/common.py new file mode 100644 index 000000000..030171c30 --- /dev/null +++ b/clients/python/tests/common.py @@ -0,0 +1,149 @@ +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +import time +import json +import socket +import logging + +logger = logging.getLogger(__name__) + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +def get_free_port(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(('0.0.0.0', 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +def find_repo_root(): + current = Path.cwd() + for path in [current, *current.parents]: + if (path / "package.json").exists(): + return path + raise Exception("Could not find repo root") + +# Run a mock actor core server on the given port +# returns a function to stop the server +def start_mock_server(): + logger.info("Starting mock server") + + # Get repo root + repo_root = find_repo_root() + logger.info(f"Found repo root: {repo_root}") + + # Build rivetkit + logger.info("Building rivetkit") + subprocess.run( + ["yarn", "build", "-F", "rivetkit"], + cwd=repo_root, + check=True + ) + + # Create temporary directory + temp_dir = tempfile.mkdtemp() + temp_path = Path(temp_dir) + logger.info(f"Created temp directory at: {temp_path}") + + # Create vendor directory + vendor_dir = temp_path / "vendor" + vendor_dir.mkdir(parents=True) + + # Pack packages + packages = [ + ("rivetkit", repo_root / "packages/rivetkit"), + ("nodejs", repo_root / "packages/platforms/nodejs"), + ("memory", repo_root / "packages/drivers/memory"), + ("file-system", repo_root / "packages/drivers/file-system") + ] + + logger.info("Packing packages (3 total)") + for name, path in packages: + output_path = vendor_dir / f"rivetkit-{name}.tgz" + subprocess.run( + ["yarn", "pack", "--out", str(output_path)], + cwd=path, + check=True + ) + + # Copy counter example + logger.info("Copying counter example to temp directory") + counter_source = repo_root / "examples/counter" + counter_dest = temp_path / "counter" + shutil.copytree(counter_source, counter_dest) + + # Create server script + logger.info("Creating server start script") + server_dir = temp_path / "counter" + server_script_path = server_dir / "run.ts" + + + port = get_free_port() + server_script = f""" +import {{ app }} from "./actors/app.ts"; +import {{ serve }} from "@rivetkit/nodejs"; + +serve(app, {{ port: {port}, mode: "memory" }}); +""" + + server_script_path.write_text(server_script) + + # Create package.json + logger.info("Creating package.json") + package_json = { + "name": "rivetkit-python-test", + "packageManager": "yarn@4.2.2", + "private": True, + "type": "module", + "dependencies": { + "rivetkit": f"file:{vendor_dir}/rivetkit-rivetkit.tgz", + "@rivetkit/nodejs": f"file:{vendor_dir}/rivetkit-nodejs.tgz", + "@rivetkit/memory": f"file:{vendor_dir}/rivetkit-memory.tgz", + "@rivetkit/file-system": f"file:{vendor_dir}/rivetkit-file-system.tgz", + }, + "devDependencies": { + "tsx": "^3.12.7" + } + } + + package_json_path = server_dir / "package.json" + package_json_path.write_text(json.dumps(package_json, indent=2)) + + # Write .yarnrc.yml + logger.info("Creating .yarnrc.yml") + yarnrc_path = server_dir / ".yarnrc.yml" + yarnrc_path.write_text("nodeLinker: node-modules\n") + + # Install dependencies + logger.info("Installing dependencies") + subprocess.run( + ["yarn"], + cwd=server_dir, + check=True + ) + + # Start the server + logger.info("Starting the server") + process = subprocess.Popen( + ["npx", "tsx", "run.ts"], + cwd=server_dir + ) + + # Wait a bit for the server to start + time.sleep(2) + + # Return cleanup function + def stop_mock_server(): + logger.info("Stopping mock server") + process.kill() + shutil.rmtree(temp_dir) + + return ("http://127.0.0.1:" + str(port), stop_mock_server) + diff --git a/clients/python/tests/test_e2e_async.py b/clients/python/tests/test_e2e_async.py new file mode 100644 index 000000000..58f3ed95a --- /dev/null +++ b/clients/python/tests/test_e2e_async.py @@ -0,0 +1,23 @@ +import asyncio +import pytest +from rivetkit_client import AsyncClient as ActorClient +from common import start_mock_server, get_free_port, logger + +@pytest.mark.asyncio +async def test_e2e_async(): + (addr, stop_mock_server) = start_mock_server() + + client = ActorClient(addr) + + handle = await client.get("counter", tags={"tag": "valu3"}) + logger.info("Actor handle: " + str(handle)) + + logger.info("Subscribed to newCount") + handle.on_event("newCount", lambda msg: logger.info("Received msg:", msg)) + + logger.info("Sending action") + assert 1 == await handle.action("increment", 1) + + await handle.disconnect() + + stop_mock_server() diff --git a/clients/python/tests/test_e2e_simple_async.py b/clients/python/tests/test_e2e_simple_async.py new file mode 100644 index 000000000..8dda89d1d --- /dev/null +++ b/clients/python/tests/test_e2e_simple_async.py @@ -0,0 +1,34 @@ +import asyncio +import pytest +from rivetkit_client import AsyncSimpleClient as ActorClient +from common import start_mock_server, logger + +async def do_oneoff_increment(client): + handle = await client.get("counter") + logger.info("Created new handle: " + str(handle)) + + logger.info("Sending increment action") + res = await handle.action("increment", 1) + # First increment on mock server, so it should be 1 + assert res == 1 + +@pytest.mark.asyncio +async def test_e2e_simple_async(): + (addr, stop_mock_server) = start_mock_server() + + client = ActorClient(addr) + + handle = await client.get("counter") + logger.info("Actor handle: " + str(handle)) + + logger.info("Subscribing to newCount") + await handle.subscribe("newCount") + + await do_oneoff_increment(client) + + logger.info("Receiving msgs") + logger.info(await handle.receive(1)) + + await handle.disconnect() + + stop_mock_server() diff --git a/clients/python/tests/test_e2e_simple_sync.py b/clients/python/tests/test_e2e_simple_sync.py new file mode 100644 index 000000000..b65c6089a --- /dev/null +++ b/clients/python/tests/test_e2e_simple_sync.py @@ -0,0 +1,31 @@ +from rivetkit_client import SimpleClient as ActorClient +from common import start_mock_server, logger + + +def do_oneoff_increment(client): + handle = client.get("counter") + logger.info("Created new handle: " + str(handle)) + + logger.info("Sending increment action") + res = handle.action("increment", 1) + # First increment on mock server, so it should be 1 + assert res == 1 + +def test_e2e_simple_sync(): + (addr, stop_mock_server) = start_mock_server() + + client = ActorClient(addr) + handle = client.get("counter") + logger.info("Actor handle: " + str(handle)) + + logger.info("Subscribing to newCount") + handle.subscribe("newCount") + + do_oneoff_increment(client) + + logger.info("Waiting for newCount event") + logger.info(handle.receive(1)) + + handle.disconnect() + + stop_mock_server() diff --git a/clients/python/tests/test_e2e_sync.py b/clients/python/tests/test_e2e_sync.py new file mode 100644 index 000000000..41d8be271 --- /dev/null +++ b/clients/python/tests/test_e2e_sync.py @@ -0,0 +1,18 @@ +from rivetkit_client import Client as ActorClient +from common import start_mock_server, logger + +def test_e2e_sync(): + (addr, stop_mock_server) = start_mock_server() + + client = ActorClient(addr) + + handle = client.get("counter") + logger.info("Actor handle: " + str(handle)) + + logger.info("Listening to newCount") + handle.on_event("newCount", lambda msg: print("Received msg:", msg)) + + logger.info("Sending action") + assert 1 == handle.action("increment", 1) + + handle.disconnect() diff --git a/clients/rust/Cargo.toml b/clients/rust/Cargo.toml new file mode 100644 index 000000000..80187da79 --- /dev/null +++ b/clients/rust/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "rivetkit-client" +version = "0.9.0-rc.2" +description = "Rust client for RivetKit - the Stateful Serverless Framework for building AI agents, realtime apps, and game servers" +edition = "2021" +authors = ["Rivet Gaming, LLC "] +license = "Apache-2.0" +homepage = "https://rivetkit.org" +repository = "https://github.com/rivet-gg/rivetkit" + +[dependencies] +anyhow = "1.0" +base64 = "0.22.1" +eventsource-client = "0.14.0" +futures-util = "0.3.31" +reqwest = "0.12.12" +serde = { version = "1.0", features = ["derive"] } +serde_cbor = "0.11.2" +serde_json = "1.0" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.26.1", features = ["native-tls", "handshake"] } +tracing = "0.1.41" +tungstenite = "0.26.2" +urlencoding = "2.1.3" + +[dev-dependencies] +tracing-subscriber = { version = "0.3.19", features = ["env-filter", "std", "registry"]} +tempfile = "3.10.1" +tokio-test = "0.4.3" +fs_extra = "1.3.0" +portpicker = "0.1.1" diff --git a/clients/rust/README.md b/clients/rust/README.md new file mode 100644 index 000000000..0b47fa284 --- /dev/null +++ b/clients/rust/README.md @@ -0,0 +1,90 @@ +# RivetKit Rust Client + +_The Rust client for RivetKit, the Stateful Serverless Framework_ + +Use this client to connect to RivetKit services from Rust applications. + +## Resources + +- [Quickstart](https://rivetkit.org/introduction) +- [Documentation](https://rivetkit.org/clients/rust) +- [Examples](https://github.com/rivet-gg/rivetkit/tree/main/examples) + +## Getting Started + +### Step 1: Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +rivetkit-client = "0.1.0" +``` + +### Step 2: Connect to Actor + +```rust +use rivetkit_client::{Client, EncodingKind, GetOrCreateOptions, TransportKind}; +use serde_json::json; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Create a client connected to your RivetKit manager + let client = Client::new( + "http://localhost:8080", + TransportKind::Sse, + EncodingKind::Json + ); + + // Connect to a chat room actor + let chat_room = client.get_or_create( + "chat-room", + ["keys-here"].into(), + GetOrCreateOptions::default() + )?.connect(); + + // Listen for new messages + chat_room.on_event("newMessage", |args| { + let username = args[0].as_str().unwrap(); + let message = args[1].as_str().unwrap(); + println!("Message from {}: {}", username, message); + }).await; + + // Send message to room + chat_room.action("sendMessage", vec![ + json!("william"), + json!("All the world's a stage.") + ]).await?; + + // When finished + client.disconnect(); + + Ok(()) +} +``` + +### Supported Transport Methods + +The Rust client supports multiple transport methods: + +- `TransportKind::Sse`: Server-Sent Events +- `TransportKind::Ws`: WebSockets + +### Supported Encodings + +The Rust client supports multiple encoding formats: + +- `EncodingKind::Json`: JSON encoding +- `EncodingKind::Cbor`: CBOR binary encoding + +## Community & Support + +- Join our [Discord](https://rivet.gg/discord) +- Follow us on [X](https://x.com/rivet_gg) +- Follow us on [Bluesky](https://bsky.app/profile/rivet.gg) +- File bug reports in [GitHub Issues](https://github.com/rivet-gg/rivetkit/issues) +- Post questions & ideas in [GitHub Discussions](https://github.com/rivet-gg/rivetkit/discussions) + +## License + +Apache 2.0 diff --git a/clients/rust/src/backoff.rs b/clients/rust/src/backoff.rs new file mode 100644 index 000000000..b9e881190 --- /dev/null +++ b/clients/rust/src/backoff.rs @@ -0,0 +1,24 @@ +use std::{cmp, time::Duration}; + +pub struct Backoff { + max_delay: Duration, + delay: Duration, +} + +impl Backoff { + pub fn new(initial: Duration, max_delay: Duration) -> Self { + Self { + max_delay, + delay: initial, + } + } + + pub fn delay(&self) -> Duration { + self.delay + } + + pub async fn tick(&mut self) { + tokio::time::sleep(self.delay).await; + self.delay = cmp::min(self.delay * 2, self.max_delay); + } +} diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs new file mode 100644 index 000000000..67e3b6e9f --- /dev/null +++ b/clients/rust/src/client.rs @@ -0,0 +1,189 @@ +use std::sync::Arc; + +use anyhow::Result; +use serde_json::{Value as JsonValue}; + +use crate::{ + common::{resolve_actor_id, ActorKey, EncodingKind, TransportKind}, + handle::ActorHandle, + protocol::query::* +}; + +#[derive(Default)] +pub struct GetWithIdOptions { + pub params: Option, +} + +#[derive(Default)] +pub struct GetOptions { + pub params: Option, +} + +#[derive(Default)] +pub struct GetOrCreateOptions { + pub params: Option, + pub create_in_region: Option, + pub create_with_input: Option, +} + +#[derive(Default)] +pub struct CreateOptions { + pub params: Option, + pub region: Option, + pub input: Option, +} + + +pub struct Client { + manager_endpoint: String, + encoding_kind: EncodingKind, + transport_kind: TransportKind, + shutdown_tx: Arc>, +} + +impl Client { + pub fn new( + manager_endpoint: &str, + transport_kind: TransportKind, + encoding_kind: EncodingKind, + ) -> Self { + Self { + manager_endpoint: manager_endpoint.to_string(), + encoding_kind, + transport_kind, + shutdown_tx: Arc::new(tokio::sync::broadcast::channel(1).0) + } + } + + fn create_handle( + &self, + params: Option, + query: ActorQuery + ) -> ActorHandle { + let handle = ActorHandle::new( + &self.manager_endpoint, + params, + query, + self.shutdown_tx.clone(), + self.transport_kind, + self.encoding_kind + ); + + handle + } + + pub fn get( + &self, + name: &str, + key: ActorKey, + opts: GetOptions + ) -> Result { + let actor_query = ActorQuery::GetForKey { + get_for_key: GetForKeyRequest { + name: name.to_string(), + key, + } + }; + + let handle = self.create_handle( + opts.params, + actor_query + ); + + Ok(handle) + } + + pub fn get_for_id( + &self, + actor_id: &str, + opts: GetOptions + ) -> Result { + let actor_query = ActorQuery::GetForId { + get_for_id: GetForIdRequest { + actor_id: actor_id.to_string(), + } + }; + + let handle = self.create_handle( + opts.params, + actor_query + ); + + Ok(handle) + } + + pub fn get_or_create( + &self, + name: &str, + key: ActorKey, + opts: GetOrCreateOptions + ) -> Result { + let input = opts.create_with_input; + let region = opts.create_in_region; + + let actor_query = ActorQuery::GetOrCreateForKey { + get_or_create_for_key: GetOrCreateRequest { + name: name.to_string(), + key: key, + input, + region + } + }; + + let handle = self.create_handle( + opts.params, + actor_query, + ); + + Ok(handle) + } + + pub async fn create( + &self, + name: &str, + key: ActorKey, + opts: CreateOptions + ) -> Result { + let input = opts.input; + let region = opts.region; + + let create_query = ActorQuery::Create { + create: CreateRequest { + name: name.to_string(), + key, + input, + region + } + }; + + let actor_id = resolve_actor_id( + &self.manager_endpoint, + create_query, + self.encoding_kind + ).await?; + + let get_query = ActorQuery::GetForId { + get_for_id: GetForIdRequest { + actor_id, + } + }; + + let handle = self.create_handle( + opts.params, + get_query + ); + + Ok(handle) + } + + pub fn disconnect(self) { + drop(self) + } +} + +impl Drop for Client { + fn drop(&mut self) { + // Notify all subscribers to shutdown + let _ = self.shutdown_tx.send(()); + } +} \ No newline at end of file diff --git a/clients/rust/src/common.rs b/clients/rust/src/common.rs new file mode 100644 index 000000000..62c62974c --- /dev/null +++ b/clients/rust/src/common.rs @@ -0,0 +1,199 @@ +use anyhow::Result; +use reqwest::{header::USER_AGENT, RequestBuilder}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Value as JsonValue}; +use tracing::debug; + +use crate::protocol::query::ActorQuery; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const USER_AGENT_VALUE: &str = concat!("ActorClient-Rust/", env!("CARGO_PKG_VERSION")); + +pub const HEADER_ACTOR_QUERY: &str = "X-AC-Query"; +pub const HEADER_ENCODING: &str = "X-AC-Encoding"; +pub const HEADER_CONN_PARAMS: &str = "X-AC-Conn-Params"; +pub const HEADER_ACTOR_ID: &str = "X-AC-Actor"; +pub const HEADER_CONN_ID: &str = "X-AC-Conn"; +pub const HEADER_CONN_TOKEN: &str = "X-AC-Conn-Token"; + +#[derive(Debug, Clone, Copy)] +pub enum TransportKind { + WebSocket, + Sse, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncodingKind { + Json, + Cbor, +} + +impl EncodingKind { + pub fn as_str(&self) -> &str { + match self { + EncodingKind::Json => "json", + EncodingKind::Cbor => "cbor", + } + } +} + +impl ToString for EncodingKind { + fn to_string(&self) -> String { + self.as_str().to_string() + } +} + + + +// Max size of each entry is 128 bytes +pub type ActorKey = Vec; + +pub struct HttpRequestOptions<'a, T: Serialize> { + pub method: &'a str, + pub url: &'a str, + pub headers: Vec<(&'a str, String)>, + pub body: Option, + pub encoding_kind: EncodingKind +} + +impl<'a, T: Serialize> Default for HttpRequestOptions<'a, T> { + fn default() -> Self { + Self { + method: "GET", + url: "", + headers: Vec::new(), + body: None, + encoding_kind: EncodingKind::Json + } + } +} + +fn build_http_request(opts: &HttpRequestOptions) -> Result +where + RQ: Serialize +{ + let client = reqwest::Client::new(); + let mut req = client.request( + reqwest::Method::from_bytes(opts.method.as_bytes()).unwrap(), + opts.url, + ); + + for (key, value) in &opts.headers { + req = req.header(*key, value); + } + + if opts.method == "POST" || opts.method == "PUT" { + let Some(body) = &opts.body else { + return Err(anyhow::anyhow!("Body is required for POST/PUT requests")); + }; + + match opts.encoding_kind { + EncodingKind::Json => { + req = req.header("Content-Type", "application/json"); + let body = serde_json::to_string(&body)?; + req = req.body(body); + } + EncodingKind::Cbor => { + req = req.header("Content-Type", "application/octet-stream"); + let body =serde_cbor::to_vec(&body)?; + req = req.body(body); + } + } + }; + + req = req.header(USER_AGENT, USER_AGENT_VALUE); + + Ok(req) +} + +async fn send_http_request_raw(req: reqwest::RequestBuilder) -> Result { + let res = req.send().await?; + + if !res.status().is_success() { + // TODO: Decode + /* + let data: Option = match opts.encoding_kind { + EncodingKind::Json => { + let data = res.text().await?; + + serde_json::from_str::(&data).ok() + } + EncodingKind::Cbor => { + let data = res.bytes().await?; + + serde_cbor::from_slice(&data).ok() + } + }; + + match data { + Some(data) => { + return Err(anyhow::anyhow!( + "HTTP request failed with status: {}, error: {}", + res.status(), + data.m + )); + }, + None => { + + } + } + */ + return Err(anyhow::anyhow!( + "HTTP request failed with status: {}", + res.status() + )); + } + + Ok(res) +} + +pub async fn send_http_request<'a, RQ, RS>(opts: HttpRequestOptions<'a, RQ>) -> Result +where + RQ: Serialize, + RS: DeserializeOwned, +{ + let req = build_http_request(&opts)?; + let res = send_http_request_raw(req).await?; + + let res: RS = match opts.encoding_kind { + EncodingKind::Json => { + let data = res.text().await?; + serde_json::from_str(&data)? + } + EncodingKind::Cbor => { + let bytes = res.bytes().await?; + serde_cbor::from_slice(&bytes)? + } + }; + + Ok(res) +} + + +pub async fn resolve_actor_id( + manager_endpoint: &str, + query: ActorQuery, + encoding_kind: EncodingKind +) -> Result { + #[derive(serde::Serialize, serde::Deserialize)] + struct ResolveResponse { + i: String, + } + + let query = serde_json::to_string(&query)?; + + let res = send_http_request::( + HttpRequestOptions { + method: "POST", + url: &format!("{}/actors/resolve", manager_endpoint), + headers: vec![ + (HEADER_ENCODING, encoding_kind.to_string()), + (HEADER_ACTOR_QUERY, query), + ], + body: Some(json!({})), + encoding_kind, + } + ).await?; + + Ok(res.i) +} \ No newline at end of file diff --git a/clients/rust/src/connection.rs b/clients/rust/src/connection.rs new file mode 100644 index 000000000..935059b74 --- /dev/null +++ b/clients/rust/src/connection.rs @@ -0,0 +1,434 @@ +use anyhow::Result; +use futures_util::FutureExt; +use serde_json::Value; +use std::fmt::Debug; +use std::ops::Deref; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::time::Duration; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::{broadcast, oneshot, watch, Mutex}; + +use crate::{ + backoff::Backoff, + protocol::{query::ActorQuery, *}, + drivers::*, + EncodingKind, + TransportKind +}; +use tracing::debug; + + +type RpcResponse = Result; +type EventCallback = dyn Fn(&Vec) + Send + Sync; + +struct SendMsgOpts { + ephemeral: bool, +} + +impl Default for SendMsgOpts { + fn default() -> Self { + Self { ephemeral: false } + } +} + +// struct WatchPair { +// tx: watch::Sender, +// rx: watch::Receiver, +// } +type WatchPair = (watch::Sender, watch::Receiver); + +pub type ActorConnection = Arc; + +struct ConnectionAttempt { + did_open: bool, + _task_end_reason: DriverStopReason, +} + +pub struct ActorConnectionInner { + endpoint: String, + transport_kind: TransportKind, + encoding_kind: EncodingKind, + query: ActorQuery, + parameters: Option, + + driver: Mutex>, + msg_queue: Mutex>>, + + rpc_counter: AtomicI64, + in_flight_rpcs: Mutex>>, + + event_subscriptions: Mutex>>>, + + dc_watch: WatchPair, + disconnection_rx: Mutex>>, +} + +impl ActorConnectionInner { + pub(crate) fn new( + endpoint: String, + query: ActorQuery, + transport_kind: TransportKind, + encoding_kind: EncodingKind, + parameters: Option, + ) -> ActorConnection { + Arc::new(Self { + endpoint: endpoint.clone(), + transport_kind, + encoding_kind, + query, + parameters, + driver: Mutex::new(None), + msg_queue: Mutex::new(Vec::new()), + rpc_counter: AtomicI64::new(0), + in_flight_rpcs: Mutex::new(HashMap::new()), + event_subscriptions: Mutex::new(HashMap::new()), + dc_watch: watch::channel(false), + disconnection_rx: Mutex::new(None), + }) + } + + fn is_disconnecting(self: &Arc) -> bool { + *self.dc_watch.1.borrow() == true + } + + async fn try_connect(self: &Arc) -> ConnectionAttempt { + let Ok((driver, mut recver, task)) = connect_driver( + self.transport_kind, + DriverConnectArgs { + endpoint: self.endpoint.clone(), + query: self.query.clone(), + encoding_kind: self.encoding_kind, + parameters: self.parameters.clone(), + } + ).await else { + // Either from immediate disconnect (local device connection refused) + // or from error like invalid URL + return ConnectionAttempt { + did_open: false, + _task_end_reason: DriverStopReason::TaskError, + }; + }; + + { + let mut my_driver = self.driver.lock().await; + *my_driver = Some(driver); + } + + let mut task_end_reason = task.map(|res| match res { + Ok(a) => a, + Err(task_err) => { + if task_err.is_cancelled() { + debug!("Connection task was cancelled"); + DriverStopReason::UserAborted + } else { + DriverStopReason::TaskError + } + } + }); + + let mut did_connection_open = false; + + // spawn listener for rpcs + let task_end_reason = loop { + tokio::select! { + reason = &mut task_end_reason => { + debug!("Connection closed: {:?}", reason); + + break reason; + }, + msg = recver.recv() => { + // If the sender is dropped, break the loop + let Some(msg) = msg else { + // break DriverStopReason::ServerDisconnect; + continue; + }; + + if let to_client::ToClientBody::Init { i: _ } = &msg.b { + did_connection_open = true; + } + + self.on_message(msg).await; + } + } + }; + + 'destroy_driver: { + debug!("Destroying driver"); + let mut d_guard = self.driver.lock().await; + let Some(d) = d_guard.take() else { + // We destroyed the driver already, + // e.g. .disconnect() was called + break 'destroy_driver; + }; + + d.disconnect(); + } + + ConnectionAttempt { + did_open: did_connection_open, + _task_end_reason: task_end_reason, + } + } + + async fn on_open(self: &Arc, init: &to_client::Init) { + debug!("Connected to server: {:?}", init); + + for (event_name, _) in self.event_subscriptions.lock().await.iter() { + self.send_subscription(event_name.clone(), true).await; + } + + // Flush message queue + for msg in self.msg_queue.lock().await.drain(..) { + // If its in the queue, it isn't ephemeral, so we pass + // default SendMsgOpts + self.send_msg(msg, SendMsgOpts::default()).await; + } + } + + async fn on_message(self: &Arc, msg: Arc) { + let body = &msg.b; + + match body { + to_client::ToClientBody::Init { i: init } => { + self.on_open(init).await; + } + to_client::ToClientBody::ActionResponse { ar } => { + let id = ar.i; + let mut in_flight_rpcs = self.in_flight_rpcs.lock().await; + let Some(tx) = in_flight_rpcs.remove(&id) else { + debug!("Unexpected response: rpc id not found"); + return; + }; + if let Err(e) = tx.send(Ok(ar.clone())) { + debug!("{:?}", e); + return; + } + } + to_client::ToClientBody::EventMessage { ev } => { + let listeners = self.event_subscriptions.lock().await; + if let Some(callbacks) = listeners.get(&ev.n) { + for cb in callbacks { + cb(&ev.a); + } + } + } + to_client::ToClientBody::Error { e } => { + if let Some(action_id) = e.ai { + let mut in_flight_rpcs = self.in_flight_rpcs.lock().await; + let Some(tx) = in_flight_rpcs.remove(&action_id) else { + debug!("Unexpected response: rpc id not found"); + return; + }; + if let Err(e) = tx.send(Err(e.clone())) { + debug!("{:?}", e); + return; + } + + return; + } + + + } + } + } + + async fn send_msg(self: &Arc, msg: Arc, opts: SendMsgOpts) { + let guard = self.driver.lock().await; + + 'send_immediately: { + let Some(driver) = guard.deref() else { + break 'send_immediately; + }; + + let Ok(_) = driver.send(msg.clone()).await else { + break 'send_immediately; + }; + + return; + } + + // Otherwise queue + if opts.ephemeral == false { + self.msg_queue.lock().await.push(msg.clone()); + } + + return; + } + + pub async fn action(self: &Arc, method: &str, params: Vec) -> Result { + let id: i64 = self.rpc_counter.fetch_add(1, Ordering::SeqCst); + + let (tx, rx) = oneshot::channel(); + self.in_flight_rpcs.lock().await.insert(id, tx); + + self.send_msg( + Arc::new(to_server::ToServer { + b: to_server::ToServerBody::ActionRequest { + ar: to_server::ActionRequest { + i: id, + n: method.to_string(), + a: params, + }, + }, + }), + SendMsgOpts::default(), + ) + .await; + + let Ok(res) = rx.await else { + // Verbosity + return Err(anyhow::anyhow!("Socket closed during rpc")); + }; + + match res { + Ok(ok) => Ok(ok.o), + Err(err) => { + let metadata = err.md.unwrap_or(Value::Null); + + Err(anyhow::anyhow!( + "RPC Error({}): {:?}, {:#}", + err.c, + err.m, + metadata + )) + } + } + } + + async fn send_subscription(self: &Arc, event_name: String, subscribe: bool) { + self.send_msg( + Arc::new(to_server::ToServer { + b: to_server::ToServerBody::SubscriptionRequest { + sr: to_server::SubscriptionRequest { + e: event_name, + s: subscribe, + }, + }, + }), + SendMsgOpts { ephemeral: true }, + ) + .await; + } + + async fn add_event_subscription( + self: &Arc, + event_name: String, + callback: Box, + ) { + // TODO: Support for once + let mut listeners = self.event_subscriptions.lock().await; + + let is_new_subscription = listeners.contains_key(&event_name) == false; + + listeners + .entry(event_name.clone()) + .or_insert(Vec::new()) + .push(callback); + + if is_new_subscription { + self.send_subscription(event_name, true).await; + } + } + + pub async fn on_event(self: &Arc, event_name: &str, callback: F) + where + F: Fn(&Vec) + Send + Sync + 'static, + { + self.add_event_subscription(event_name.to_string(), Box::new(callback)) + .await + } + + pub async fn disconnect(self: &Arc) { + if self.is_disconnecting() { + // We are already disconnecting + return; + } + + debug!("Disconnecting from actor conn"); + + self.dc_watch.0.send(true).ok(); + + if let Some(d) = self.driver.lock().await.deref() { + d.disconnect(); + } + self.in_flight_rpcs.lock().await.clear(); + self.event_subscriptions.lock().await.clear(); + let Some(rx) = self.disconnection_rx.lock().await.take() else { + return; + }; + + rx.await.ok(); + } +} + + +pub fn start_connection( + conn: &Arc, + mut shutdown_rx: broadcast::Receiver<()> +) { + let (tx, rx) = oneshot::channel(); + + let conn = conn.clone(); + + tokio::spawn(async move { + { + let mut stop_rx = conn.disconnection_rx.lock().await; + if stop_rx.is_some() { + // Already doing connection_with_retry + // - this drops the oneshot + return; + } + + *stop_rx = Some(rx); + } + + 'keepalive: loop { + debug!("Attempting to reconnect"); + let mut backoff = Backoff::new(Duration::from_secs(1), Duration::from_secs(30)); + let mut retry_attempt = 0; + 'retry: loop { + retry_attempt += 1; + debug!( + "Establish conn: attempt={}, timeout={:?}", + retry_attempt, + backoff.delay() + ); + let attempt = conn.try_connect().await; + + if conn.is_disconnecting() { + break 'keepalive; + } + + if attempt.did_open { + break 'retry; + } + + let mut dc_rx = conn.dc_watch.0.subscribe(); + + tokio::select! { + _ = backoff.tick() => {}, + _ = dc_rx.wait_for(|x| *x == true) => { + break 'keepalive; + } + _ = shutdown_rx.recv() => { + debug!("Received shutdown signal, stopping connection attempts"); + break 'keepalive; + } + } + } + } + + tx.send(()).ok(); + conn.disconnection_rx.lock().await.take(); + }); +} + +impl Debug for ActorConnectionInner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActorConnection") + .field("endpoint", &self.endpoint) + .field("transport_kind", &self.transport_kind) + .field("encoding_kind", &self.encoding_kind) + .finish() + } +} \ No newline at end of file diff --git a/clients/rust/src/drivers/mod.rs b/clients/rust/src/drivers/mod.rs new file mode 100644 index 000000000..8db37c88a --- /dev/null +++ b/clients/rust/src/drivers/mod.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use crate::{ + protocol::{query, to_client, to_server}, + EncodingKind, TransportKind +}; +use anyhow::Result; +use serde_json::Value; +use tokio::{ + sync::mpsc, + task::{AbortHandle, JoinHandle}, +}; +use tracing::debug; + +pub mod sse; +pub mod ws; + +pub type MessageToClient = Arc; +pub type MessageToServer = Arc; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DriverStopReason { + UserAborted, + ServerDisconnect, + ServerError, + TaskError, +} + +#[derive(Debug)] +pub struct DriverHandle { + abort_handle: AbortHandle, + sender: mpsc::Sender, +} + +impl DriverHandle { + pub fn new(sender: mpsc::Sender, abort_handle: AbortHandle) -> Self { + Self { + sender, + abort_handle, + } + } + + pub async fn send(&self, msg: Arc) -> Result<()> { + self.sender.send(msg).await?; + + Ok(()) + } + + pub fn disconnect(&self) { + self.abort_handle.abort(); + } +} + +impl Drop for DriverHandle { + fn drop(&mut self) { + debug!("DriverHandle dropped, aborting task"); + self.disconnect() + } +} + +pub type DriverConnection = ( + DriverHandle, + mpsc::Receiver, + JoinHandle, +); + +pub struct DriverConnectArgs { + pub endpoint: String, + pub encoding_kind: EncodingKind, + pub query: query::ActorQuery, + pub parameters: Option, +} + +pub async fn connect_driver( + transport_kind: TransportKind, + args: DriverConnectArgs +) -> Result { + let res = match transport_kind { + TransportKind::WebSocket => ws::connect(args).await?, + TransportKind::Sse => sse::connect(args).await?, + }; + + Ok(res) +} diff --git a/clients/rust/src/drivers/sse.rs b/clients/rust/src/drivers/sse.rs new file mode 100644 index 000000000..be314baa7 --- /dev/null +++ b/clients/rust/src/drivers/sse.rs @@ -0,0 +1,243 @@ +use anyhow::{Result}; +use base64::prelude::*; +use eventsource_client::{BoxStream, Client, ClientBuilder, ReconnectOptionsBuilder, SSE}; +use futures_util::StreamExt; +use reqwest::header::USER_AGENT; +use std::sync::Arc; +use tokio::sync::mpsc; +use tracing::debug; + +use crate::{ + common::{EncodingKind, HEADER_ACTOR_ID, HEADER_ACTOR_QUERY, HEADER_CONN_ID, HEADER_CONN_PARAMS, HEADER_CONN_TOKEN, HEADER_ENCODING, USER_AGENT_VALUE}, + protocol::{to_client, to_server} +}; + +use super::{ + DriverConnectArgs, DriverConnection, DriverHandle, DriverStopReason, MessageToClient, MessageToServer +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ConnectionDetails { + actor_id: String, + id: String, + token: String, +} + + +struct Context { + conn: ConnectionDetails, + encoding_kind: EncodingKind, + endpoint: String, +} + +pub(crate) async fn connect(args: DriverConnectArgs) -> Result { + let endpoint = format!("{}/actors/connect/sse", args.endpoint); + + let params_string = match args.parameters { + Some(p) => Some(serde_json::to_string(&p)).transpose(), + None => Ok(None), + }?; + + let client = ClientBuilder::for_url(&endpoint)? + .header(USER_AGENT.as_str(), USER_AGENT_VALUE)? + .header(HEADER_ENCODING, args.encoding_kind.as_str())? + .header(HEADER_ACTOR_QUERY, serde_json::to_string(&args.query)?.as_str())?; + + let client = match params_string { + Some(p) => client.header(HEADER_CONN_PARAMS, p.as_str())?, + None => client, + }; + let client = client.reconnect(ReconnectOptionsBuilder::new(false).build()) + .build(); + + let (in_tx, in_rx) = mpsc::channel::(32); + let (out_tx, out_rx) = mpsc::channel::(32); + + let task = tokio::spawn(start(client, args.endpoint, args.encoding_kind, in_tx, out_rx)); + + let handle = DriverHandle::new(out_tx, task.abort_handle()); + Ok((handle, in_rx, task)) +} + +async fn sse_send_msg(ctx: &Context, msg: MessageToServer) -> Result { + let msg = serialize(ctx.encoding_kind, &msg)?; + + // Add connection ID and token to the request URL + let request_url = format!( + "{}/actors/message", + ctx.endpoint + ); + + let res = reqwest::Client::new() + .post(request_url) + .body(msg) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(HEADER_ENCODING, ctx.encoding_kind.as_str()) + .header(HEADER_ACTOR_ID, ctx.conn.actor_id.as_str()) + .header(HEADER_CONN_ID, ctx.conn.id.as_str()) + .header(HEADER_CONN_TOKEN, ctx.conn.token.as_str()) + .send() + .await?; + + + if !res.status().is_success() { + return Err(anyhow::anyhow!("Failed to send message: {:?}", res)); + } + + let res = res.text().await?; + + Ok(res) +} + +async fn start( + client: impl Client, + endpoint: String, + encoding_kind: EncodingKind, + in_tx: mpsc::Sender, + mut out_rx: mpsc::Receiver, +) -> DriverStopReason { + let mut stream = client.stream(); + + let ctx = Context { + conn: match do_handshake(&mut stream, encoding_kind, &in_tx).await { + Ok(conn) => conn, + Err(reason) => return reason + }, + encoding_kind, + endpoint, + }; + + debug!("Handshake completed successfully"); + + loop { + tokio::select! { + // Handle outgoing messages + msg = out_rx.recv() => { + let Some(msg) = msg else { + return DriverStopReason::UserAborted; + }; + + let res = match sse_send_msg(&ctx, msg).await { + Ok(res) => res, + Err(e) => { + debug!("Failed to send message: {:?}", e); + continue; + } + }; + + debug!("Response: {:?}", res); + }, + msg = stream.next() => { + let Some(msg) = msg else { + // Receiver dropped + return DriverStopReason::ServerDisconnect; + }; + + match msg { + Ok(msg) => match msg { + SSE::Comment(comment) => debug!("Sse comment: {}", comment), + SSE::Connected(_) => debug!("warning: received sse connection past-handshake"), + SSE::Event(event) => { + let msg = match deserialize(encoding_kind, &event.data) { + Ok(msg) => msg, + Err(e) => { + debug!("Failed to deserialize {:?} {:?}", event, e); + continue; + } + }; + + if let Err(e) = in_tx.send(Arc::new(msg)).await { + debug!("Receiver in_rx dropped {:?}", e); + return DriverStopReason::UserAborted; + } + }, + } + Err(e) => { + debug!("Sse error: {}", e); + return DriverStopReason::ServerError; + } + } + } + } + } +} + +async fn do_handshake( + stream: &mut BoxStream>, + encoding_kind: EncodingKind, + in_tx: &mpsc::Sender, +) -> Result { + loop { + tokio::select! { + // Handle sse incoming + msg = stream.next() => { + let Some(msg) = msg else { + debug!("Receiver dropped"); + return Err(DriverStopReason::ServerDisconnect); + }; + + match msg { + Ok(msg) => match msg { + SSE::Comment(comment) => debug!("Sse comment {:?}", comment), + SSE::Connected(_) => debug!("Connected Sse"), + SSE::Event(event) => { + let msg = match deserialize(encoding_kind, &event.data) { + Ok(msg) => msg, + Err(e) => { + debug!("Failed to deserialize {:?} {:?}", event, e); + continue; + } + }; + + let msg = Arc::new(msg); + + if let Err(e) = in_tx.send(msg.clone()).await { + debug!("Receiver in_rx dropped {:?}", e); + return Err(DriverStopReason::UserAborted); + } + + // Wait until we get an Init packet + let to_client::ToClientBody::Init { i } = &msg.b else { + continue; + }; + + // Mark handshake complete + return Ok(ConnectionDetails { + actor_id: i.ai.to_string(), + id: i.ci.clone(), + token: i.ct.clone() + }) + }, + } + Err(e) => { + eprintln!("Sse error: {}", e); + return Err(DriverStopReason::ServerError); + } + } + } + } + } +} + +fn deserialize(encoding_kind: EncodingKind, msg: &str) -> Result { + match encoding_kind { + EncodingKind::Json => { + Ok(serde_json::from_str::(msg)?) + }, + EncodingKind::Cbor => { + let msg = serde_cbor::from_slice::( + &BASE64_STANDARD.decode(msg.as_bytes())? + )?; + + Ok(msg) + } + } +} + +fn serialize(encoding_kind: EncodingKind, msg: &to_server::ToServer) -> Result> { + match encoding_kind { + EncodingKind::Json => Ok(serde_json::to_vec(msg)?), + EncodingKind::Cbor => Ok(serde_cbor::to_vec(msg)?), + } +} + diff --git a/clients/rust/src/drivers/ws.rs b/clients/rust/src/drivers/ws.rs new file mode 100644 index 000000000..e8d694500 --- /dev/null +++ b/clients/rust/src/drivers/ws.rs @@ -0,0 +1,176 @@ +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; +use tracing::debug; + +use crate::{ + protocol::to_server, + protocol::to_client, + EncodingKind +}; + +use super::{ + DriverConnectArgs, DriverConnection, DriverHandle, DriverStopReason, MessageToClient, MessageToServer +}; + +fn build_connection_url(args: &DriverConnectArgs) -> Result { + let actor_query_string = serde_json::to_string(&args.query)?; + // TODO: Should replace http:// only at the start of the string + let url = args.endpoint + .to_string() + .replace("http://", "ws://") + .replace("https://", "wss://"); + + let url = format!( + "{}/actors/connect/websocket?encoding={}&query={}", + url, + args.encoding_kind.as_str(), + urlencoding::encode(&actor_query_string) + ); + + Ok(url) +} + + +pub(crate) async fn connect(args: DriverConnectArgs) -> Result { + let url = build_connection_url(&args)?; + + debug!("Connecting to: {}", url); + + let (ws, _res) = tokio_tungstenite::connect_async(url) + .await + .context("Failed to connect to WebSocket")?; + + let (in_tx, in_rx) = mpsc::channel::(32); + let (out_tx, out_rx) = mpsc::channel::(32); + + let task = tokio::spawn(start(ws, args.encoding_kind, in_tx, out_rx)); + let handle = DriverHandle::new(out_tx, task.abort_handle()); + + handle.send(Arc::new( + to_server::ToServer { + b: to_server::ToServerBody::Init { + i: to_server::Init { + p: args.parameters + } + }, + } + )).await?; + + Ok((handle, in_rx, task)) +} + +async fn start( + ws: WebSocketStream>, + encoding_kind: EncodingKind, + in_tx: mpsc::Sender, + mut out_rx: mpsc::Receiver, +) -> DriverStopReason { + let (mut ws_sink, mut ws_stream) = ws.split(); + + let serialize = get_msg_serializer(encoding_kind); + let deserialize = get_msg_deserializer(encoding_kind); + + loop { + tokio::select! { + // Dispatch ws outgoing queue + msg = out_rx.recv() => { + // If the sender is dropped, break the loop + let Some(msg) = msg else { + debug!("Sender dropped"); + return DriverStopReason::UserAborted; + }; + + let msg = match serialize(&msg) { + Ok(msg) => msg, + Err(e) => { + debug!("Failed to serialize message: {:?}", e); + continue; + } + }; + + if let Err(e) = ws_sink.send(msg).await { + debug!("Failed to send message: {:?}", e); + continue; + } + }, + // Handle ws incoming + msg = ws_stream.next() => { + let Some(msg) = msg else { + println!("Receiver dropped"); + return DriverStopReason::ServerDisconnect; + }; + + match msg { + Ok(msg) => match msg { + Message::Text(_) | Message::Binary(_) => { + let Ok(msg) = deserialize(&msg) else { + debug!("Failed to parse message: {:?}", msg); + continue; + }; + + if let Err(e) = in_tx.send(Arc::new(msg)).await { + debug!("Failed to send text message: {}", e); + // failure to send means user dropped incoming receiver + return DriverStopReason::UserAborted; + } + }, + Message::Close(_) => { + debug!("Close message"); + return DriverStopReason::ServerDisconnect; + }, + _ => { + debug!("Invalid message type received"); + } + } + Err(e) => { + debug!("WebSocket error: {}", e); + return DriverStopReason::ServerError; + } + } + } + } + } +} + +fn get_msg_deserializer(encoding_kind: EncodingKind) -> fn(&Message) -> Result { + match encoding_kind { + EncodingKind::Json => json_msg_deserialize, + EncodingKind::Cbor => cbor_msg_deserialize, + } +} + +fn get_msg_serializer(encoding_kind: EncodingKind) -> fn(&to_server::ToServer) -> Result { + match encoding_kind { + EncodingKind::Json => json_msg_serialize, + EncodingKind::Cbor => cbor_msg_serialize, + } +} + +fn json_msg_deserialize(value: &Message) -> Result { + match value { + Message::Text(text) => Ok(serde_json::from_str(text)?), + Message::Binary(bin) => Ok(serde_json::from_slice(bin)?), + _ => Err(anyhow::anyhow!("Invalid message type")), + } +} + +fn cbor_msg_deserialize(value: &Message) -> Result { + match value { + Message::Binary(bin) => Ok(serde_cbor::from_slice(bin)?), + Message::Text(text) => Ok(serde_cbor::from_slice(text.as_bytes())?), + _ => Err(anyhow::anyhow!("Invalid message type")), + } +} + +fn json_msg_serialize(value: &to_server::ToServer) -> Result { + Ok(Message::Text(serde_json::to_string(value)?.into())) +} + +fn cbor_msg_serialize(value: &to_server::ToServer) -> Result { + Ok(Message::Binary(serde_cbor::to_vec(value)?.into())) +} diff --git a/clients/rust/src/handle.rs b/clients/rust/src/handle.rs new file mode 100644 index 000000000..4abda92af --- /dev/null +++ b/clients/rust/src/handle.rs @@ -0,0 +1,178 @@ +use std::{cell::RefCell, ops::Deref, sync::Arc}; +use serde_json::Value as JsonValue; +use anyhow::{anyhow, Result}; +use urlencoding::encode as url_encode; +use crate::{ + common::{resolve_actor_id, send_http_request, HttpRequestOptions, HEADER_ACTOR_QUERY, HEADER_CONN_PARAMS, HEADER_ENCODING}, + connection::{start_connection, ActorConnection, ActorConnectionInner}, + protocol::query::*, + EncodingKind, + TransportKind +}; + +pub struct ActorHandleStateless { + endpoint: String, + params: Option, + encoding_kind: EncodingKind, + query: RefCell, +} + +impl ActorHandleStateless { + pub fn new( + endpoint: &str, + params: Option, + encoding_kind: EncodingKind, + query: ActorQuery + ) -> Self { + Self { + endpoint: endpoint.to_string(), + params, + encoding_kind, + query: RefCell::new(query) + } + } + + pub async fn action(&self, name: &str, args: Vec) -> Result { + #[derive(serde::Serialize)] + struct ActionRequest { + a: Vec, + } + #[derive(serde::Deserialize)] + struct ActionResponse { + o: JsonValue, + } + + let actor_query = serde_json::to_string(&self.query)?; + + // Build headers + let mut headers = vec![ + (HEADER_ENCODING, self.encoding_kind.to_string()), + (HEADER_ACTOR_QUERY, actor_query), + ]; + + if let Some(params) = &self.params { + headers.push((HEADER_CONN_PARAMS, serde_json::to_string(params)?)); + } + + let res = send_http_request::(HttpRequestOptions { + url: &format!( + "{}/actors/actions/{}", + self.endpoint, + url_encode(name) + ), + method: "POST", + headers, + body: Some(ActionRequest { + a: args, + }), + encoding_kind: self.encoding_kind, + }).await?; + + Ok(res.o) + } + + pub async fn resolve(&self) -> Result { + let query = { + // None of this is async or runs on multithreads, + // it cannot fail given that both borrows are + // well contained, and cannot overlap. + let Ok(query) = self.query.try_borrow() else { + return Err(anyhow!("Failed to borrow actor query")); + }; + + query.clone() + }; + + match query { + ActorQuery::Create { create: _query } => { + Err(anyhow!("actor query cannot be create")) + }, + ActorQuery::GetForId { get_for_id: query } => { + Ok(query.clone().actor_id) + }, + _ => { + let actor_id = resolve_actor_id( + &self.endpoint, + query, + self.encoding_kind + ).await?; + + { + let Ok(mut query) = self.query.try_borrow_mut() else { + // Following code will not run (see prior note) + return Err(anyhow!("Failed to borrow actor query mutably")); + }; + + *query = ActorQuery::GetForId { + get_for_id: GetForIdRequest { + actor_id: actor_id.clone(), + } + }; + } + + Ok(actor_id) + } + } + } +} + +pub struct ActorHandle { + handle: ActorHandleStateless, + endpoint: String, + params: Option, + query: ActorQuery, + client_shutdown_tx: Arc>, + transport_kind: crate::TransportKind, + encoding_kind: EncodingKind, +} + +impl ActorHandle { + pub fn new( + endpoint: &str, + params: Option, + query: ActorQuery, + client_shutdown_tx: Arc>, + transport_kind: TransportKind, + encoding_kind: EncodingKind + ) -> Self { + let handle = ActorHandleStateless::new( + endpoint, + params.clone(), + encoding_kind, + query.clone() + ); + + Self { + handle, + endpoint: endpoint.to_string(), + params, + query, + client_shutdown_tx, + transport_kind, + encoding_kind, + } + } + + pub fn connect(&self) -> ActorConnection { + let conn = ActorConnectionInner::new( + self.endpoint.clone(), + self.query.clone(), + self.transport_kind, + self.encoding_kind, + self.params.clone() + ); + + let rx = self.client_shutdown_tx.subscribe(); + start_connection(&conn, rx); + + conn + } +} + +impl Deref for ActorHandle { + type Target = ActorHandleStateless; + + fn deref(&self) -> &Self::Target { + &self.handle + } +} \ No newline at end of file diff --git a/clients/rust/src/lib.rs b/clients/rust/src/lib.rs new file mode 100644 index 000000000..0bc31def2 --- /dev/null +++ b/clients/rust/src/lib.rs @@ -0,0 +1,10 @@ +mod backoff; +mod common; +pub mod client; +pub mod drivers; +pub mod connection; +pub mod handle; +pub mod protocol; + +pub use client::{Client, CreateOptions, GetOptions, GetOrCreateOptions, GetWithIdOptions}; +pub use common::{TransportKind, EncodingKind}; diff --git a/clients/rust/src/protocol/mod.rs b/clients/rust/src/protocol/mod.rs new file mode 100644 index 000000000..bb4f0579d --- /dev/null +++ b/clients/rust/src/protocol/mod.rs @@ -0,0 +1,3 @@ +pub mod to_server; +pub mod to_client; +pub mod query; \ No newline at end of file diff --git a/clients/rust/src/protocol/query.rs b/clients/rust/src/protocol/query.rs new file mode 100644 index 000000000..88cd2849a --- /dev/null +++ b/clients/rust/src/protocol/query.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +use crate::common::ActorKey; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateRequest { + pub name: String, + pub key: ActorKey, + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetForKeyRequest { + pub name: String, + pub key: ActorKey, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetForIdRequest { + #[serde(rename = "actorId")] + pub actor_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetOrCreateRequest { + pub name: String, + pub key: ActorKey, + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ActorQuery { + GetForId { + #[serde(rename = "getForId")] + get_for_id: GetForIdRequest, + }, + GetForKey { + #[serde(rename = "getForKey")] + get_for_key: GetForKeyRequest, + }, + GetOrCreateForKey { + #[serde(rename = "getOrCreateForKey")] + get_or_create_for_key: GetOrCreateRequest, + }, + Create { + create: CreateRequest, + }, +} \ No newline at end of file diff --git a/clients/rust/src/protocol/to_client.rs b/clients/rust/src/protocol/to_client.rs new file mode 100644 index 000000000..5fe67d484 --- /dev/null +++ b/clients/rust/src/protocol/to_client.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +// Only called for SSE because we don't need this for WebSockets +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Init { + // Actor ID + pub ai: String, + // Connection ID + pub ci: String, + // Connection token + pub ct: String, +} + +// Used for connection errors (both during initialization and afterwards) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Error { + // Code + pub c: String, + // Message + pub m: String, + // Metadata + #[serde(skip_serializing_if = "Option::is_none")] + pub md: Option, + // Action ID + #[serde(skip_serializing_if = "Option::is_none")] + pub ai: Option +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActionResponse { + // ID + pub i: i64, + // Output + pub o: JsonValue +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Event { + // Event name + pub n: String, + // Event arguments + pub a: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ToClientBody { + Init { i: Init }, + Error { e: Error }, + ActionResponse { ar: ActionResponse }, + EventMessage { ev: Event }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToClient { + // Body + pub b: ToClientBody, +} \ No newline at end of file diff --git a/clients/rust/src/protocol/to_server.rs b/clients/rust/src/protocol/to_server.rs new file mode 100644 index 000000000..5f4a3c1d4 --- /dev/null +++ b/clients/rust/src/protocol/to_server.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Init { + // Conn Params + #[serde(skip_serializing_if = "Option::is_none")] + pub p: Option +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActionRequest { + // ID + pub i: i64, + // Name + pub n: String, + // Args + pub a: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscriptionRequest { + // Event name + pub e: String, + // Subscribe + pub s: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ToServerBody { + Init { i: Init }, + ActionRequest { ar: ActionRequest }, + SubscriptionRequest { sr: SubscriptionRequest }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToServer { + pub b: ToServerBody, +} diff --git a/clients/rust/tests/e2e.rs b/clients/rust/tests/e2e.rs new file mode 100644 index 000000000..016373748 --- /dev/null +++ b/clients/rust/tests/e2e.rs @@ -0,0 +1,205 @@ +use rivetkit_client::{Client, EncodingKind, GetOrCreateOptions, TransportKind}; +use fs_extra; +use portpicker; +use serde_json::json; +use tracing_subscriber::EnvFilter; +use std::process::{Child, Command}; +use std::time::Duration; +use tempfile; +use tokio::time::sleep; +use tracing::{error, info}; + +/// Manages a mock server process for testing +struct MockServer { + child: Child, + // Keep the tempdir alive until this struct is dropped + _temp_dir: tempfile::TempDir, +} + +impl MockServer { + async fn start(port: u16) -> Self { + // Get the repo root directory based on current file location + let current_dir = std::env::current_dir().expect("Failed to get current directory"); + let repo_root = current_dir + .ancestors() + .find(|p| p.join("package.json").exists()) + .expect("Failed to find repo root"); + + // Run `pnpm build -F rivetkit` in the root of this repo + let status = Command::new("yarn") + .args(["build", "-F", "rivetkit"]) + .current_dir(&repo_root) + .status() + .expect("Failed to build rivetkit"); + + if !status.success() { + panic!("Failed to build rivetkit"); + } + + // Create a temporary directory for the test server + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let temp_path = temp_dir.path(); + println!("Created temp directory at: {}", temp_path.display()); + + // Create vendor directory in the temp dir + let vendor_dir = temp_path.join("vendor"); + std::fs::create_dir_all(&vendor_dir).expect("Failed to create vendor directory"); + + // Define packages to pack + let packages = [ + ("rivetkit", repo_root.join("packages/rivetkit")), + ("nodejs", repo_root.join("packages/platforms/nodejs")), + ("memory", repo_root.join("packages/drivers/memory")), + ("file-system", repo_root.join("packages/drivers/file-system")), + ]; + + // Pack each package to the vendor directory + for (name, path) in packages.iter() { + let output_path = vendor_dir.join(format!("rivetkit-{}.tgz", name)); + println!( + "Packing {} from {} to {}", + name, + path.display(), + output_path.display() + ); + + let status = Command::new("yarn") + .args(["pack", "--out", output_path.to_str().unwrap()]) + .current_dir(path) + .status() + .expect(&format!("Failed to pack {}", name)); + + if !status.success() { + panic!("Failed to pack {}", name); + } + } + + // Copy examples/counter to the temp dir + let counter_dir = repo_root.join("examples/counter"); + let options = fs_extra::dir::CopyOptions::new(); + fs_extra::dir::copy(&counter_dir, temp_path, &options) + .expect("Failed to copy counter example"); + + // Create the server directory structure + let server_dir = temp_path.join("counter"); + let server_script_path = server_dir.join("run.ts"); + + // Write the server script + let server_script = r#" +import { app } from "./actors/app.ts"; +import { serve } from "@rivetkit/nodejs"; + +serve(app, { port: PORT, mode: "memory" }); +"# + .replace("PORT", &port.to_string()); + + std::fs::write(&server_script_path, server_script).expect("Failed to write server script"); + + // Write a new package.json with tarball dependencies + let package_json_path = server_dir.join("package.json"); + let package_json = format!( + r#"{{ + "name": "rivetkit-rust-test", + "packageManager": "yarn@4.2.2", + "private": true, + "type": "module", + "dependencies": {{ + "rivetkit": "file:{}", + "@rivetkit/nodejs": "file:{}", + "@rivetkit/memory": "file:{}", + "@rivetkit/file-system": "file:{}" + }}, + "devDependencies": {{ + "tsx": "^3.12.7" + }} +}}"#, + vendor_dir.join("rivetkit-rivetkit.tgz").display(), + vendor_dir.join("rivetkit-nodejs.tgz").display(), + vendor_dir.join("rivetkit-memory.tgz").display(), + vendor_dir.join("rivetkit-file-system.tgz").display() + ); + + std::fs::write(&package_json_path, package_json).expect("Failed to write package.json"); + + // Write a .yarnrc.yml file to use node-modules linker + let yarnrc_path = server_dir.join(".yarnrc.yml"); + let yarnrc_content = "nodeLinker: node-modules\n"; + std::fs::write(&yarnrc_path, yarnrc_content).expect("Failed to write .yarnrc.yml"); + + // Install dependencies + let status = Command::new("yarn") + .current_dir(&server_dir) + .status() + .expect("Failed to install dependencies"); + + if !status.success() { + panic!("Failed to install dependencies"); + } + + // Spawn the server process + let child = Command::new("npx") + .args(["tsx", "run.ts"]) + .current_dir(&server_dir) + .spawn() + .expect("Failed to spawn server process"); + + Self { + child, + _temp_dir: temp_dir, + } + } +} + +impl Drop for MockServer { + fn drop(&mut self) { + // Kill the server process + if let Err(e) = self.child.kill() { + error!("Failed to kill server: {}", e); + } + + // Note: The temporary directory is automatically cleaned up when the tempfile::TempDir + // value is dropped, which happens when the test finishes + + info!("Mock server terminated"); + } +} + +#[tokio::test] +async fn e2e() { + // Configure logging + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::DEBUG) + // .with_env_filter(EnvFilter::new("rivetkit_client=trace,hyper=error")) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + // Pick an available port + let port = portpicker::pick_unused_port().expect("Failed to pick an unused port"); + info!("Using port {}", port); + let endpoint = format!("http://127.0.0.1:{}", port); + + // Start the mock server + let _server = MockServer::start(port).await; + // Wait for server to start + info!("Waiting for server to start..."); + sleep(Duration::from_secs(2)).await; + + // Create the client + info!("Creating client to endpoint: {}", endpoint); + let client = Client::new(&endpoint, TransportKind::WebSocket, EncodingKind::Cbor); + let counter = client.get_or_create("counter", [].into(), GetOrCreateOptions::default()) + .unwrap(); + let conn = counter.connect(); + + conn.on_event("newCount", |x| { + info!("Received newCount event: {:?}", x); + }).await; + + let out = counter.action("increment", vec![json!(1)]).await.unwrap(); + info!("Action 1: {:?}", out); + let out = conn.action("increment", vec![json!(1)]).await.unwrap(); + info!("Action 2: {:?}", out); + + // Clean up + client.disconnect(); +} diff --git a/examples/ai-agent/README.md b/examples/ai-agent/README.md new file mode 100644 index 000000000..274868d37 --- /dev/null +++ b/examples/ai-agent/README.md @@ -0,0 +1,40 @@ +# AI Agent Chat for RivetKit + +Example project demonstrating AI agent integration with [RivetKit](https://rivetkit.org). + +[Learn More →](https://github.com/rivet-gg/rivetkit) + +[Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- OpenAI API key + +### Installation + +```sh +git clone https://github.com/rivet-gg/rivetkit +cd rivetkit/examples/ai-agent +npm install +``` + +### Development + +1. Set your OpenAI API key: +```sh +export OPENAI_API_KEY=your-api-key-here +``` + +2. Start the development server: +```sh +npm run dev +``` + +3. Open your browser to `http://localhost:3000` + +## License + +Apache 2.0 \ No newline at end of file diff --git a/examples/ai-agent/package.json b/examples/ai-agent/package.json new file mode 100644 index 000000000..4b459be8b --- /dev/null +++ b/examples/ai-agent/package.json @@ -0,0 +1,35 @@ +{ + "name": "example-ai-agent", + "version": "0.9.5", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev:backend": "tsx --watch src/backend/server.ts", + "dev:frontend": "vite", + "build": "vite build", + "check-types": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "concurrently": "^8.2.2", + "@rivetkit/actor": "workspace:*", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "vite": "^5.0.0", + "vitest": "^3.1.1" + }, + "dependencies": { + "@ai-sdk/openai": "^0.0.66", + "@rivetkit/react": "workspace:*", + "ai": "^4.0.38", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zod": "^3.25.69" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/ai-agent/src/backend/my-utils.ts b/examples/ai-agent/src/backend/my-utils.ts new file mode 100644 index 000000000..a0ef451be --- /dev/null +++ b/examples/ai-agent/src/backend/my-utils.ts @@ -0,0 +1,11 @@ +export async function getWeather(location: string) { + // Mock weather API response + return { + location, + temperature: Math.floor(Math.random() * 30) + 10, + condition: ["sunny", "cloudy", "rainy", "snowy"][ + Math.floor(Math.random() * 4) + ], + humidity: Math.floor(Math.random() * 50) + 30, + }; +} diff --git a/examples/ai-agent/src/backend/registry.ts b/examples/ai-agent/src/backend/registry.ts new file mode 100644 index 000000000..4898e4584 --- /dev/null +++ b/examples/ai-agent/src/backend/registry.ts @@ -0,0 +1,70 @@ +import { openai } from "@ai-sdk/openai"; +import { actor, setup } from "@rivetkit/actor"; +import { generateText, tool } from "ai"; +import { z } from "zod"; +import { getWeather } from "./my-utils"; + +export type Message = { + role: "user" | "assistant"; + content: string; + timestamp: number; +}; + +export const aiAgent = actor({ + onAuth: () => {}, + // Persistent state that survives restarts: https://rivet.gg/docs/actors/state + state: { + messages: [] as Message[], + }, + + actions: { + // Callable functions from clients: https://rivet.gg/docs/actors/actions + getMessages: (c) => c.state.messages, + + sendMessage: async (c, userMessage: string) => { + const userMsg: Message = { + role: "user", + content: userMessage, + timestamp: Date.now(), + }; + // State changes are automatically persisted + c.state.messages.push(userMsg); + + const { text } = await generateText({ + model: openai("gpt-4o-mini"), + prompt: userMessage, + messages: c.state.messages, + tools: { + weather: tool({ + description: "Get the weather in a location", + parameters: z.object({ + location: z + .string() + .describe("The location to get the weather for"), + }), + execute: async ({ location }) => { + return await getWeather(location); + }, + }), + }, + }); + + const assistantMsg: Message = { + role: "assistant", + content: text, + timestamp: Date.now(), + }; + c.state.messages.push(assistantMsg); + + // Send events to all connected clients: https://rivet.gg/docs/actors/events + c.broadcast("messageReceived", assistantMsg); + + return assistantMsg; + }, + }, +}); + +// Register actors for use: https://rivet.gg/docs/setup +export const registry = setup({ + use: { aiAgent }, +}); diff --git a/examples/ai-agent/src/backend/server.ts b/examples/ai-agent/src/backend/server.ts new file mode 100644 index 000000000..fd02aa970 --- /dev/null +++ b/examples/ai-agent/src/backend/server.ts @@ -0,0 +1,7 @@ +import { registry } from "./registry"; + +registry.runServer({ + cors: { + origin: "*", + }, +}); diff --git a/examples/ai-agent/src/frontend/App.tsx b/examples/ai-agent/src/frontend/App.tsx new file mode 100644 index 000000000..e4b5b5f1b --- /dev/null +++ b/examples/ai-agent/src/frontend/App.tsx @@ -0,0 +1,80 @@ +import { createClient, createRivetKit } from "@rivetkit/react"; +import { useEffect, useState } from "react"; +import type { Message, registry } from "../backend/registry"; + +const client = createClient("http://localhost:8080"); +const { useActor } = createRivetKit(client); + +export function App() { + const aiAgent = useActor({ + name: "aiAgent", + key: ["default"], + }); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (aiAgent.connection) { + aiAgent.connection.getMessages().then(setMessages); + } + }, [aiAgent.connection]); + + aiAgent.useEvent("messageReceived", (message: Message) => { + setMessages((prev) => [...prev, message]); + setIsLoading(false); + }); + + const handleSendMessage = async () => { + if (aiAgent.connection && input.trim()) { + setIsLoading(true); + + const userMessage = { role: "user", content: input, timestamp: Date.now() } as Message; + setMessages((prev) => [...prev, userMessage]); + + await aiAgent.connection.sendMessage(input); + setInput(""); + } + }; + + return ( +
+
+ {messages.length === 0 ? ( +
+ Ask the AI assistant a question to get started +
+ ) : ( + messages.map((msg, i) => ( +
+
{msg.role === "user" ? "👤" : "🤖"}
+
{msg.content}
+
+ )) + )} + {isLoading && ( +
+
🤖
+
Thinking...
+
+ )} +
+ +
+ setInput(e.target.value)} + onKeyPress={(e) => e.key === "Enter" && handleSendMessage()} + placeholder="Ask the AI assistant..." + disabled={isLoading} + /> + +
+
+ ); +} \ No newline at end of file diff --git a/examples/ai-agent/src/frontend/index.html b/examples/ai-agent/src/frontend/index.html new file mode 100644 index 000000000..342668108 --- /dev/null +++ b/examples/ai-agent/src/frontend/index.html @@ -0,0 +1,116 @@ + + + + + + AI Agent Example + + + +
+ + + \ No newline at end of file diff --git a/examples/ai-agent/src/frontend/main.tsx b/examples/ai-agent/src/frontend/main.tsx new file mode 100644 index 000000000..9b08c1764 --- /dev/null +++ b/examples/ai-agent/src/frontend/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Root element not found"); + +createRoot(root).render( + + + +); \ No newline at end of file diff --git a/examples/ai-agent/tests/ai-agent.test.ts b/examples/ai-agent/tests/ai-agent.test.ts new file mode 100644 index 000000000..02ced0fb9 --- /dev/null +++ b/examples/ai-agent/tests/ai-agent.test.ts @@ -0,0 +1,117 @@ +import { setupTest } from "@rivetkit/actor/test"; +import { expect, test, vi } from "vitest"; +import { registry } from "../src/backend/registry"; + +// Mock the AI SDK and OpenAI +vi.mock("@ai-sdk/openai", () => ({ + openai: () => "mock-model", +})); + +vi.mock("ai", () => ({ + generateText: vi.fn().mockImplementation(async ({ prompt }) => ({ + text: `AI response to: ${prompt}`, + })), + tool: vi.fn().mockImplementation(({ execute }) => ({ execute })), +})); + +vi.mock("../src/backend/my-utils", () => ({ + getWeather: vi.fn().mockResolvedValue({ + location: "San Francisco", + temperature: 72, + condition: "sunny", + }), +})); + +test("AI Agent can handle basic actions without connection", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const agent = client.aiAgent.getOrCreate(["test-basic"]); + + // Test initial state + const initialMessages = await agent.getMessages(); + expect(initialMessages).toEqual([]); + + // Send a message + const userMessage = "Hello, how are you?"; + const response = await agent.sendMessage(userMessage); + + // Verify response structure + expect(response).toMatchObject({ + role: "assistant", + content: expect.stringContaining("AI response to: Hello, how are you?"), + timestamp: expect.any(Number), + }); + + // Verify messages are stored + const messages = await agent.getMessages(); + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: "user", + content: userMessage, + timestamp: expect.any(Number), + }); + expect(messages[1]).toEqual(response); +}); + +test("AI Agent maintains conversation history", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const agent = client.aiAgent.getOrCreate(["test-history"]); + + // Send multiple messages + await agent.sendMessage("First message"); + await agent.sendMessage("Second message"); + await agent.sendMessage("Third message"); + + const messages = await agent.getMessages(); + expect(messages).toHaveLength(6); // 3 user + 3 assistant messages + + // Verify message ordering and roles + expect(messages[0].role).toBe("user"); + expect(messages[0].content).toBe("First message"); + expect(messages[1].role).toBe("assistant"); + expect(messages[2].role).toBe("user"); + expect(messages[2].content).toBe("Second message"); + expect(messages[3].role).toBe("assistant"); + expect(messages[4].role).toBe("user"); + expect(messages[4].content).toBe("Third message"); + expect(messages[5].role).toBe("assistant"); +}); + +test("AI Agent handles weather tool usage", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const agent = client.aiAgent.getOrCreate(["test-weather"]); + + // Send a weather-related message + const response = await agent.sendMessage( + "What's the weather in San Francisco?", + ); + + // Verify response was generated + expect(response.role).toBe("assistant"); + expect(response.content).toContain( + "AI response to: What's the weather in San Francisco?", + ); + expect(response.timestamp).toBeGreaterThan(0); + + // Verify message history includes both user and assistant messages + const messages = await agent.getMessages(); + expect(messages).toHaveLength(2); + expect(messages[0].content).toBe("What's the weather in San Francisco?"); + expect(messages[1]).toEqual(response); +}); + +test("AI Agent timestamps are sequential", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const agent = client.aiAgent.getOrCreate(["test-timestamps"]); + + const response1 = await agent.sendMessage("First"); + const response2 = await agent.sendMessage("Second"); + + expect(response2.timestamp).toBeGreaterThanOrEqual(response1.timestamp); + + const messages = await agent.getMessages(); + for (let i = 1; i < messages.length; i++) { + expect(messages[i].timestamp).toBeGreaterThanOrEqual( + messages[i - 1].timestamp, + ); + } +}); diff --git a/examples/ai-agent/tsconfig.json b/examples/ai-agent/tsconfig.json new file mode 100644 index 000000000..c368563fe --- /dev/null +++ b/examples/ai-agent/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext", "dom"], + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node", "vite/client"], + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/ai-agent/turbo.json b/examples/ai-agent/turbo.json new file mode 100644 index 000000000..95960709b --- /dev/null +++ b/examples/ai-agent/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/examples/ai-agent/vite.config.ts b/examples/ai-agent/vite.config.ts new file mode 100644 index 000000000..bf42f65b6 --- /dev/null +++ b/examples/ai-agent/vite.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + root: "src/frontend", + server: { + port: 3000, + }, +}); diff --git a/packages/google-drive/vitest.config.ts b/examples/ai-agent/vitest.config.ts similarity index 85% rename from packages/google-drive/vitest.config.ts rename to examples/ai-agent/vitest.config.ts index 4ba6db64a..5a32eade8 100644 --- a/packages/google-drive/vitest.config.ts +++ b/examples/ai-agent/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - testTimeout: 60000, include: ["tests/**/*.test.ts"], + testTimeout: 30000, }, }); diff --git a/examples/better-auth-external-db/.gitignore b/examples/better-auth-external-db/.gitignore new file mode 100644 index 000000000..959e16e69 --- /dev/null +++ b/examples/better-auth-external-db/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules +.pnpm-debug.log* + +# Build outputs +dist +.turbo + +# Environment variables +.env +.env.local +.env.production.local +.env.development.local + +# Database +*.sqlite +*.db + +# IDE +.vscode +.idea + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/examples/better-auth-external-db/README.md b/examples/better-auth-external-db/README.md new file mode 100644 index 000000000..a7acac3e8 --- /dev/null +++ b/examples/better-auth-external-db/README.md @@ -0,0 +1,62 @@ +# Better Auth with External Database for RivetKit + +Example project demonstrating authentication integration with [RivetKit](https://rivetkit.org) using Better Auth and SQLite database. + +[Learn More →](https://github.com/rivet-gg/rivetkit) + +[Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- npm or pnpm + +### Installation + +```sh +git clone https://github.com/rivet-gg/rivetkit +cd rivetkit/examples/better-auth-external-db +npm install +``` + +### Development + +```sh +npm run dev +``` + +The database migrations will run automatically on startup. Open your browser to `http://localhost:5173` to see the frontend and the backend will be running on `http://localhost:8080`. + +## Features + +- **Authentication**: Email/password authentication using Better Auth +- **Protected Actors**: Rivet Actors with authentication via `onAuth` hook +- **Real-time Chat**: Authenticated chat room with real-time messaging +- **External Database**: Shows how to configure Better Auth with external database (SQLite example) + +## How It Works + +1. **Better Auth Setup**: Configured with SQLite database for persistent user storage (auto-migrated in development) +2. **Protected Actor**: The `chatRoom` actor uses the `onAuth` hook to verify user sessions +3. **Frontend Integration**: React components handle authentication flow and chat interface +4. **Session Management**: Better Auth handles session creation, validation, and cleanup +5. **Auto-Migration**: Database schema is automatically migrated when starting the development server + +## Database Commands + +- `npm run db:generate` - Generate migration files for database schema changes +- `npm run db:migrate` - Apply migrations to the database (used in production) + +## Key Files + +- `src/backend/auth.ts` - Better Auth configuration with SQLite database +- `src/backend/registry.ts` - Rivet Actor with authentication +- `src/frontend/components/AuthForm.tsx` - Login/signup form +- `src/frontend/components/ChatRoom.tsx` - Authenticated chat interface +- `auth.sqlite` - SQLite database file (auto-created) + +## License + +Apache 2.0 diff --git a/examples/better-auth-external-db/better-auth_migrations/2025-06-27T06-53-24.043Z.sql b/examples/better-auth-external-db/better-auth_migrations/2025-06-27T06-53-24.043Z.sql new file mode 100644 index 000000000..13b82ff1d --- /dev/null +++ b/examples/better-auth-external-db/better-auth_migrations/2025-06-27T06-53-24.043Z.sql @@ -0,0 +1,7 @@ +create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" integer not null, "image" text, "createdAt" date not null, "updatedAt" date not null); + +create table "session" ("id" text not null primary key, "expiresAt" date not null, "token" text not null unique, "createdAt" date not null, "updatedAt" date not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id")); + +create table "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id"), "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" date, "refreshTokenExpiresAt" date, "scope" text, "password" text, "createdAt" date not null, "updatedAt" date not null); + +create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" date not null, "createdAt" date, "updatedAt" date); \ No newline at end of file diff --git a/examples/better-auth-external-db/package.json b/examples/better-auth-external-db/package.json new file mode 100644 index 000000000..529d43ee5 --- /dev/null +++ b/examples/better-auth-external-db/package.json @@ -0,0 +1,38 @@ +{ + "name": "example-better-auth-external-db", + "version": "0.9.5", + "private": true, + "type": "module", + "scripts": { + "dev": "pnpm db:migrate && concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev:backend": "tsx --watch src/backend/server.ts", + "dev:frontend": "vite", + "build": "vite build", + "check-types": "tsc --noEmit", + "test": "vitest run", + "db:generate": "pnpm dlx @better-auth/cli@latest generate --config src/backend/auth.ts", + "db:migrate": "pnpm dlx @better-auth/cli@latest migrate --config src/backend/auth.ts -y" + }, + "devDependencies": { + "@rivetkit/actor": "workspace:*", + "@types/node": "^22.13.9", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "concurrently": "^8.2.2", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "vite": "^5.0.0", + "vitest": "^3.1.1" + }, + "dependencies": { + "@rivetkit/react": "workspace:*", + "@types/better-sqlite3": "^7.6.13", + "better-auth": "^1.0.1", + "better-sqlite3": "^11.10.0", + "hono": "^4.7.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/better-auth-external-db/src/backend/auth.ts b/examples/better-auth-external-db/src/backend/auth.ts new file mode 100644 index 000000000..8711420d9 --- /dev/null +++ b/examples/better-auth-external-db/src/backend/auth.ts @@ -0,0 +1,10 @@ +import { betterAuth } from "better-auth"; +import Database from "better-sqlite3"; + +export const auth = betterAuth({ + database: new Database("/tmp/auth.sqlite"), + trustedOrigins: ["http://localhost:5173"], + emailAndPassword: { + enabled: true, + }, +}); diff --git a/examples/better-auth-external-db/src/backend/registry.ts b/examples/better-auth-external-db/src/backend/registry.ts new file mode 100644 index 000000000..c2ef1e63e --- /dev/null +++ b/examples/better-auth-external-db/src/backend/registry.ts @@ -0,0 +1,59 @@ +import { actor, type OnAuthOptions, setup } from "@rivetkit/actor"; +import { Unauthorized } from "@rivetkit/actor/errors"; +import { auth } from "./auth"; + +interface State { + messages: Message[]; +} + +interface Message { + id: string; + userId: string; + username: string; + message: string; + timestamp: number; +} + +export const chatRoom = actor({ + // onAuth runs on the server & before connecting to the actor + onAuth: async (c: OnAuthOptions) => { + // ✨ NEW ✨ Access Better Auth session + const authResult = await auth.api.getSession({ + headers: c.req.headers, + }); + if (!authResult) throw new Unauthorized(); + + // Passes auth data to the actor (c.conn.auth) + return { + user: authResult.user, + session: authResult.session, + }; + }, + state: { + messages: [], + } as State, + actions: { + sendMessage: (c, message: string) => { + // ✨ NEW ✨ — Access Better Auth with c.conn.auth + const newMessage = { + id: crypto.randomUUID(), + userId: c.conn.auth.user.id, + username: c.conn.auth.user.name, + message, + timestamp: Date.now(), + }; + + c.state.messages.push(newMessage); + c.broadcast("newMessage", newMessage); + + return newMessage; + }, + getMessages: (c) => { + return c.state.messages; + }, + }, +}); + +export const registry = setup({ + use: { chatRoom }, +}); diff --git a/examples/better-auth-external-db/src/backend/server.ts b/examples/better-auth-external-db/src/backend/server.ts new file mode 100644 index 000000000..d8f0f035a --- /dev/null +++ b/examples/better-auth-external-db/src/backend/server.ts @@ -0,0 +1,29 @@ +import { ALLOWED_PUBLIC_HEADERS } from "@rivetkit/actor"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { auth } from "./auth"; +import { registry } from "./registry"; + +// Start RivetKit +const { serve } = registry.createServer(); + +// Setup router +const app = new Hono(); + +app.use( + "*", + cors({ + origin: ["http://localhost:5173"], + // Need to allow custom headers used in RivetKit + allowHeaders: ["Authorization", ...ALLOWED_PUBLIC_HEADERS], + allowMethods: ["POST", "GET", "OPTIONS"], + exposeHeaders: ["Content-Length"], + maxAge: 600, + credentials: true, + }), +); + +// Mount Better Auth routes +app.on(["GET", "POST"], "/api/auth/**", (c) => auth.handler(c.req.raw)); + +serve(app); diff --git a/examples/better-auth-external-db/src/frontend/App.tsx b/examples/better-auth-external-db/src/frontend/App.tsx new file mode 100644 index 000000000..49bc8abd9 --- /dev/null +++ b/examples/better-auth-external-db/src/frontend/App.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import { authClient } from "./auth-client"; +import { AuthForm } from "./components/AuthForm"; +import { ChatRoom } from "./components/ChatRoom"; + +function App() { + const [user, setUser] = useState<{ id: string; email: string } | null>(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Check if user is already authenticated + const checkAuth = async () => { + try { + const session = await authClient.getSession(); + if (session.data?.user) { + setUser(session.data.user); + } + } catch (error) { + console.error("Auth check failed:", error); + } finally { + setLoading(false); + } + }; + + checkAuth(); + }, []); + + const handleAuthSuccess = async () => { + try { + const session = await authClient.getSession(); + if (session.data?.user) { + setUser(session.data.user); + } + } catch (error) { + console.error("Failed to get user after auth:", error); + } + }; + + const handleSignOut = () => { + setUser(null); + }; + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + return ( +
+
+

+ RivetKit with Better Auth +

+ + {user ? ( + + ) : ( + + )} +
+
+ ); +} + +export default App; diff --git a/examples/better-auth-external-db/src/frontend/auth-client.ts b/examples/better-auth-external-db/src/frontend/auth-client.ts new file mode 100644 index 000000000..64c8a7b5a --- /dev/null +++ b/examples/better-auth-external-db/src/frontend/auth-client.ts @@ -0,0 +1,5 @@ +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient({ + baseURL: "http://localhost:8080", +}); diff --git a/examples/better-auth-external-db/src/frontend/components/AuthForm.tsx b/examples/better-auth-external-db/src/frontend/components/AuthForm.tsx new file mode 100644 index 000000000..643ce707c --- /dev/null +++ b/examples/better-auth-external-db/src/frontend/components/AuthForm.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { authClient } from "../auth-client"; + +interface AuthFormProps { + onAuthSuccess: () => void; +} + +export function AuthForm({ onAuthSuccess }: AuthFormProps) { + const [isLogin, setIsLogin] = useState(true); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + if (isLogin) { + await authClient.signIn.email({ + email, + password, + }); + } else { + await authClient.signUp.email({ + email, + name, + password, + }); + } + onAuthSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : "Authentication failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+

{isLogin ? "Sign In" : "Sign Up"}

+ +
+
+ + setEmail(e.target.value)} + required + style={{ width: "100%", padding: "8px", marginTop: "5px" }} + /> +
+ + {!isLogin && ( +
+ + setName(e.target.value)} + required + style={{ width: "100%", padding: "8px", marginTop: "5px" }} + /> +
+ )} + +
+ + setPassword(e.target.value)} + required + style={{ width: "100%", padding: "8px", marginTop: "5px" }} + /> +
+ + {error &&
{error}
} + + +
+ +
+ +
+
+ ); +} diff --git a/examples/better-auth-external-db/src/frontend/components/ChatRoom.tsx b/examples/better-auth-external-db/src/frontend/components/ChatRoom.tsx new file mode 100644 index 000000000..d26226029 --- /dev/null +++ b/examples/better-auth-external-db/src/frontend/components/ChatRoom.tsx @@ -0,0 +1,188 @@ +import { createClient, createRivetKit } from "@rivetkit/react"; +import { useEffect, useState } from "react"; +import type { registry } from "../../backend/registry"; +import { authClient } from "../auth-client"; + +const client = createClient("http://localhost:8080"); + +const { useActor } = createRivetKit(client); + +interface ChatRoomProps { + user: { id: string; email: string }; + onSignOut: () => void; +} + +export function ChatRoom({ user, onSignOut }: ChatRoomProps) { + const [message, setMessage] = useState(""); + const [messages, setMessages] = useState< + Array<{ + id: string; + userId: string; + username: string; + message: string; + timestamp: number; + }> + >([]); + const [roomId] = useState("general"); + + const chatRoom = useActor({ + name: "chatRoom", + key: [roomId], + }); + + // Listen for new messages + chatRoom.useEvent("newMessage", (newMessage) => { + setMessages((prev) => [ + ...prev, + newMessage as { + id: string; + userId: string; + username: string; + message: string; + timestamp: number; + }, + ]); + }); + + // Load initial messages when connected + useEffect(() => { + if (chatRoom.connection) { + chatRoom.connection.getMessages().then((initialMessages) => { + setMessages(initialMessages); + }); + } + }, [chatRoom.connection]); + + const handleSendMessage = async (e: React.FormEvent) => { + e.preventDefault(); + if (!message.trim() || !chatRoom.connection) return; + + try { + await chatRoom.connection.sendMessage(message.trim()); + setMessage(""); + } catch (error) { + console.error("Failed to send message:", error); + } + }; + + const handleSignOut = async () => { + await authClient.signOut(); + onSignOut(); + }; + + return ( +
+
+
+

Chat Room: {roomId}

+

Logged in as: {user.email}

+
+ +
+ +
+ {messages.length === 0 ? ( +

+ No messages yet. Start the conversation! +

+ ) : ( + messages.map((msg) => ( +
+
+ {msg.username} • {new Date(msg.timestamp).toLocaleTimeString()} +
+
{msg.message}
+
+ )) + )} +
+ +
+ setMessage(e.target.value)} + placeholder="Type your message..." + style={{ + flex: 1, + padding: "10px", + border: "1px solid #ccc", + borderRadius: "4px", + }} + /> + +
+ +
+ Connection Status: {chatRoom.connection ? "Connected" : "Connecting..."} +
+
+ ); +} diff --git a/examples/better-auth-external-db/src/frontend/index.html b/examples/better-auth-external-db/src/frontend/index.html new file mode 100644 index 000000000..c8973df5d --- /dev/null +++ b/examples/better-auth-external-db/src/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + RivetKit + Better Auth + + +
+ + + \ No newline at end of file diff --git a/examples/better-auth-external-db/src/frontend/main.tsx b/examples/better-auth-external-db/src/frontend/main.tsx new file mode 100644 index 000000000..6d0ba7949 --- /dev/null +++ b/examples/better-auth-external-db/src/frontend/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/better-auth-external-db/tsconfig.json b/examples/better-auth-external-db/tsconfig.json new file mode 100644 index 000000000..7895675a4 --- /dev/null +++ b/examples/better-auth-external-db/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "esnext", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["esnext", "dom"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "esnext", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", + /* Specify type package names to be included without being referenced in a source file. */ + "types": ["node"], + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/examples/better-auth-external-db/turbo.json b/examples/better-auth-external-db/turbo.json new file mode 100644 index 000000000..95960709b --- /dev/null +++ b/examples/better-auth-external-db/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/examples/better-auth-external-db/vite.config.ts b/examples/better-auth-external-db/vite.config.ts new file mode 100644 index 000000000..2abc7c6f8 --- /dev/null +++ b/examples/better-auth-external-db/vite.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + root: "src/frontend", + build: { + outDir: "../../dist", + }, + server: { + host: "0.0.0.0", + }, +}); diff --git a/examples/chat-room/.gitignore b/examples/chat-room/.gitignore new file mode 100644 index 000000000..79b7a1192 --- /dev/null +++ b/examples/chat-room/.gitignore @@ -0,0 +1,2 @@ +.actorcore +node_modules \ No newline at end of file diff --git a/examples/chat-room/README.md b/examples/chat-room/README.md new file mode 100644 index 000000000..6b181e8b7 --- /dev/null +++ b/examples/chat-room/README.md @@ -0,0 +1,57 @@ +# Chat Room for RivetKit + +Example project demonstrating real-time messaging and actor state management with [RivetKit](https://rivetkit.org). + +[Learn More →](https://github.com/rivet-gg/rivetkit) + +[Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) + +## Getting Started + +### Prerequisites + +- Node.js 18+ + +### Installation + +```sh +git clone https://github.com/rivet-gg/rivetkit +cd rivetkit/examples/chat-room +npm install +``` + +### Development + +#### Web UI +Start the development server with both backend and React frontend: + +```sh +npm run dev +``` + +Open your browser to `http://localhost:3000` to use the web chat interface. + +#### CLI Interface +Alternatively, use the CLI interface: + +```sh +npm run dev:cli +``` + +Or connect programmatically: + +```sh +tsx src/scripts/connect.ts +``` + +## Features + +- Real-time messaging with automatic persistence +- Multiple chat rooms support +- Both web and CLI interfaces +- Event-driven architecture with RivetKit actors +- TypeScript support throughout + +## License + +Apache 2.0 \ No newline at end of file diff --git a/examples/chat-room/package.json b/examples/chat-room/package.json new file mode 100644 index 000000000..bc1e75999 --- /dev/null +++ b/examples/chat-room/package.json @@ -0,0 +1,34 @@ +{ + "name": "chat-room", + "version": "0.9.5", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev:backend": "tsx --watch src/backend/server.ts", + "dev:frontend": "vite", + "dev:cli": "tsx src/scripts/cli.ts", + "check-types": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "@types/prompts": "^2", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "concurrently": "^8.2.2", + "prompts": "^2.4.2", + "@rivetkit/actor": "workspace:*", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "vite": "^5.0.0", + "vitest": "^3.1.1" + }, + "dependencies": { + "@rivetkit/react": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/chat-room/src/backend/registry.ts b/examples/chat-room/src/backend/registry.ts new file mode 100644 index 000000000..853da3c0a --- /dev/null +++ b/examples/chat-room/src/backend/registry.ts @@ -0,0 +1,30 @@ +import { actor, setup } from "@rivetkit/actor"; + +export type Message = { sender: string; text: string; timestamp: number }; + +export const chatRoom = actor({ + onAuth: () => {}, + // Persistent state that survives restarts: https://rivet.gg/docs/actors/state + state: { + messages: [] as Message[], + }, + + actions: { + // Callable functions from clients: https://rivet.gg/docs/actors/actions + sendMessage: (c, sender: string, text: string) => { + const message = { sender, text, timestamp: Date.now() }; + // State changes are automatically persisted + c.state.messages.push(message); + // Send events to all connected clients: https://rivet.gg/docs/actors/events + c.broadcast("newMessage", message); + return message; + }, + + getHistory: (c) => c.state.messages, + }, +}); + +// Register actors for use: https://rivet.gg/docs/setup +export const registry = setup({ + use: { chatRoom }, +}); diff --git a/examples/chat-room/src/backend/server.ts b/examples/chat-room/src/backend/server.ts new file mode 100644 index 000000000..fd02aa970 --- /dev/null +++ b/examples/chat-room/src/backend/server.ts @@ -0,0 +1,7 @@ +import { registry } from "./registry"; + +registry.runServer({ + cors: { + origin: "*", + }, +}); diff --git a/examples/chat-room/src/frontend/App.tsx b/examples/chat-room/src/frontend/App.tsx new file mode 100644 index 000000000..24c7e0320 --- /dev/null +++ b/examples/chat-room/src/frontend/App.tsx @@ -0,0 +1,100 @@ +import { createClient, createRivetKit } from "@rivetkit/react"; +import { useEffect, useState } from "react"; +import type { Message, registry } from "../backend/registry"; + +const client = createClient("http://localhost:8080"); +const { useActor } = createRivetKit(client); + +export function App() { + const [roomId, setRoomId] = useState("general"); + const [username, setUsername] = useState("User"); + const [input, setInput] = useState(""); + const [messages, setMessages] = useState([]); + + const chatRoom = useActor({ + name: "chatRoom", + key: [roomId], + }); + + useEffect(() => { + if (chatRoom.connection) { + chatRoom.connection.getHistory().then(setMessages); + } + }, [chatRoom.connection]); + + chatRoom.useEvent("newMessage", (message: Message) => { + setMessages((prev) => [...prev, message]); + }); + + const sendMessage = async () => { + if (chatRoom.connection && input.trim()) { + await chatRoom.connection.sendMessage(username, input); + setInput(""); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + sendMessage(); + } + }; + + return ( +
+
+

Chat Room: {roomId}

+
+ +
+ + setRoomId(e.target.value)} + placeholder="Enter room name" + /> + + setUsername(e.target.value)} + placeholder="Enter your username" + /> +
+ +
+ {messages.length === 0 ? ( +
+ No messages yet. Start the conversation! +
+ ) : ( + messages.map((msg, i) => ( +
+
{msg.sender}
+
{msg.text}
+
+ {new Date(msg.timestamp).toLocaleTimeString()} +
+
+ )) + )} +
+ +
+ setInput(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Type a message..." + disabled={!chatRoom.connection} + /> + +
+
+ ); +} \ No newline at end of file diff --git a/examples/chat-room/src/frontend/index.html b/examples/chat-room/src/frontend/index.html new file mode 100644 index 000000000..91526270f --- /dev/null +++ b/examples/chat-room/src/frontend/index.html @@ -0,0 +1,113 @@ + + + + + + Chat Room Example + + + +
+ + + \ No newline at end of file diff --git a/examples/chat-room/src/frontend/main.tsx b/examples/chat-room/src/frontend/main.tsx new file mode 100644 index 000000000..9b08c1764 --- /dev/null +++ b/examples/chat-room/src/frontend/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Root element not found"); + +createRoot(root).render( + + + +); \ No newline at end of file diff --git a/examples/chat-room/src/scripts/cli.ts b/examples/chat-room/src/scripts/cli.ts new file mode 100644 index 000000000..50eeae87a --- /dev/null +++ b/examples/chat-room/src/scripts/cli.ts @@ -0,0 +1,69 @@ +import { createClient } from "@rivetkit/actor/client"; +import prompts from "prompts"; +import type { registry } from "../backend/registry"; + +async function main() { + const { username, room } = await initPrompt(); + + // Create type-aware client + const client = createClient("http://localhost:8080"); + + // connect to chat room + const chatRoom = client.chatRoom.getOrCreate([room]).connect(); + + // fetch history + const history = await chatRoom.getHistory(); + console.log( + `History:\n${history.map((m) => `[${m.sender}] ${m.text}`).join("\n")}`, + ); + + // listen for new messages + let needsNewLine = false; + chatRoom.on("newMessage", (message: any) => { + if (needsNewLine) { + needsNewLine = false; + console.log(); + } + console.log(`[${message.sender}] ${message.text}`); + }); + + // loop to send messages + while (true) { + needsNewLine = true; + const message = await textPrompt("Message"); + if (!message) break; + needsNewLine = false; + await chatRoom.sendMessage(username, message); + } + + await chatRoom.dispose(); +} + +async function initPrompt(): Promise<{ + room: string; + username: string; +}> { + return await prompts([ + { + type: "text", + name: "username", + message: "Username", + }, + { + type: "text", + name: "room", + message: "Room", + }, + ]); +} + +async function textPrompt(message: string): Promise { + const { x } = await prompts({ + type: "text", + name: "x", + message, + }); + return x; +} + +main(); diff --git a/examples/chat-room/src/scripts/connect.ts b/examples/chat-room/src/scripts/connect.ts new file mode 100644 index 000000000..129fa3259 --- /dev/null +++ b/examples/chat-room/src/scripts/connect.ts @@ -0,0 +1,30 @@ +/// +import { createClient } from "@rivetkit/actor/client"; +import type { registry } from "../backend/registry"; + +async function main() { + // Create type-aware client + const client = createClient( + process.env.ENDPOINT ?? "http://localhost:8080", + ); + + // connect to chat room + const chatRoom = client.chatRoom.getOrCreate().connect(); + + // call action to get existing messages + const messages = await chatRoom.getHistory(); + console.log("Messages:", messages); + + // listen for new messages + chatRoom.on("newMessage", (message: any) => + console.log(`Message from ${message.sender}: ${message.text}`), + ); + + // send message to room + await chatRoom.sendMessage("william", "All the world's a stage."); + + // disconnect from actor when finished + await chatRoom.dispose(); +} + +main(); diff --git a/examples/chat-room/tests/chat-room.test.ts b/examples/chat-room/tests/chat-room.test.ts new file mode 100644 index 000000000..1eea29914 --- /dev/null +++ b/examples/chat-room/tests/chat-room.test.ts @@ -0,0 +1,91 @@ +import { setupTest } from "@rivetkit/actor/test"; +import { expect, test } from "vitest"; +import { registry } from "../src/backend/registry"; + +test("Chat room can handle message sending and history", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.chatRoom.getOrCreate(["test-room"]); + + // Test initial state + const initialHistory = await room.getHistory(); + expect(initialHistory).toEqual([]); + + // Send a message + const message1 = await room.sendMessage("Alice", "Hello everyone!"); + + // Verify message structure + expect(message1).toMatchObject({ + sender: "Alice", + text: "Hello everyone!", + timestamp: expect.any(Number), + }); + + // Send another message + const message2 = await room.sendMessage("Bob", "Hi Alice!"); + + // Verify messages are stored in order + const history = await room.getHistory(); + expect(history).toHaveLength(2); + expect(history[0]).toEqual(message1); + expect(history[1]).toEqual(message2); +}); + +test("Chat room message timestamps are sequential", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.chatRoom.getOrCreate(["test-timestamps"]); + + const message1 = await room.sendMessage("User1", "First message"); + const message2 = await room.sendMessage("User2", "Second message"); + const message3 = await room.sendMessage("User1", "Third message"); + + expect(message2.timestamp).toBeGreaterThanOrEqual(message1.timestamp); + expect(message3.timestamp).toBeGreaterThanOrEqual(message2.timestamp); + + const history = await room.getHistory(); + for (let i = 1; i < history.length; i++) { + expect(history[i].timestamp).toBeGreaterThanOrEqual( + history[i - 1].timestamp, + ); + } +}); + +test("Chat room supports multiple users", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.chatRoom.getOrCreate(["test-multiuser"]); + + // Multiple users sending messages + await room.sendMessage("Alice", "Hello!"); + await room.sendMessage("Bob", "Hey there!"); + await room.sendMessage("Charlie", "Good morning!"); + await room.sendMessage("Alice", "How is everyone?"); + + const history = await room.getHistory(); + expect(history).toHaveLength(4); + + // Verify senders + expect(history[0].sender).toBe("Alice"); + expect(history[1].sender).toBe("Bob"); + expect(history[2].sender).toBe("Charlie"); + expect(history[3].sender).toBe("Alice"); + + // Verify message content + expect(history[0].text).toBe("Hello!"); + expect(history[1].text).toBe("Hey there!"); + expect(history[2].text).toBe("Good morning!"); + expect(history[3].text).toBe("How is everyone?"); +}); + +test("Chat room handles empty messages", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.chatRoom.getOrCreate(["test-empty"]); + + // Test empty message + const emptyMessage = await room.sendMessage("User", ""); + expect(emptyMessage.text).toBe(""); + expect(emptyMessage.sender).toBe("User"); + expect(emptyMessage.timestamp).toBeGreaterThan(0); + + const history = await room.getHistory(); + expect(history).toHaveLength(1); + expect(history[0]).toEqual(emptyMessage); +}); diff --git a/examples/chat-room/tsconfig.json b/examples/chat-room/tsconfig.json new file mode 100644 index 000000000..757a13a94 --- /dev/null +++ b/examples/chat-room/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "esnext", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["esnext", "dom"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "esnext", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", + /* Specify type package names to be included without being referenced in a source file. */ + "types": ["node", "vite/client"], + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true + }, + "include": ["src/**/*", "actors/**/*", "tests/**/*"] +} diff --git a/examples/chat-room/turbo.json b/examples/chat-room/turbo.json new file mode 100644 index 000000000..95960709b --- /dev/null +++ b/examples/chat-room/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/examples/chat-room/vite.config.ts b/examples/chat-room/vite.config.ts new file mode 100644 index 000000000..bf42f65b6 --- /dev/null +++ b/examples/chat-room/vite.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + root: "src/frontend", + server: { + port: 3000, + }, +}); diff --git a/packages/s3/vitest.config.ts b/examples/chat-room/vitest.config.ts similarity index 85% rename from packages/s3/vitest.config.ts rename to examples/chat-room/vitest.config.ts index 4ba6db64a..5bdee0020 100644 --- a/packages/s3/vitest.config.ts +++ b/examples/chat-room/vitest.config.ts @@ -2,7 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - testTimeout: 60000, include: ["tests/**/*.test.ts"], }, }); diff --git a/examples/cloudflare-workers-hono/README.md b/examples/cloudflare-workers-hono/README.md new file mode 100644 index 000000000..1e0b0097c --- /dev/null +++ b/examples/cloudflare-workers-hono/README.md @@ -0,0 +1,63 @@ +# Cloudflare Workers with Hono for RivetKit + +Example project demonstrating Cloudflare Workers deployment with Hono router using [RivetKit](https://rivetkit.org). + +[Learn More →](https://github.com/rivet-gg/rivetkit) + +[Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) + +## Getting Started + +### Prerequisites + +- Node.js +- Cloudflare account with Actors enabled +- Wrangler CLI installed globally (`npm install -g wrangler`) + +### Installation + +```sh +git clone https://github.com/rivet-gg/rivetkit +cd rivetkit/examples/cloudflare-workers-hono +npm install +``` + +### Development + +```sh +npm run dev +``` + +This will start the Cloudflare Workers development server locally at http://localhost:8787. + +### Testing the Application + +You can test the Hono router endpoint by making a POST request to increment a counter: + +```sh +curl -X POST http://localhost:8787/increment/my-counter +``` + +Or run the client script to interact with your actors: + +```sh +npm run client +``` + +### Deploy to Cloudflare + +First, authenticate with Cloudflare: + +```sh +wrangler login +``` + +Then deploy: + +```sh +npm run deploy +``` + +## License + +Apache 2.0 diff --git a/examples/cloudflare-workers-hono/package.json b/examples/cloudflare-workers-hono/package.json new file mode 100644 index 000000000..c6ca9a3ae --- /dev/null +++ b/examples/cloudflare-workers-hono/package.json @@ -0,0 +1,25 @@ +{ + "name": "example-cloudflare-workers-hono", + "version": "0.9.5", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "check-types": "tsc --noEmit", + "client": "tsx scripts/client.ts" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250129.0", + "@rivetkit/actor": "workspace:*", + "@types/node": "^22.13.9", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "wrangler": "^4.22.0" + }, + "dependencies": { + "@rivetkit/cloudflare-workers": "workspace:*", + "hono": "^4.8.0" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/cloudflare-workers-hono/scripts/client.ts b/examples/cloudflare-workers-hono/scripts/client.ts new file mode 100644 index 000000000..65eb83856 --- /dev/null +++ b/examples/cloudflare-workers-hono/scripts/client.ts @@ -0,0 +1,9 @@ +async function main() { + const endpoint = process.env.RIVETKIT_ENDPOINT || "http://localhost:8787"; + const res = await fetch(`${endpoint}/increment/foo`, { + method: "POST", + }); + console.log("Output:", await res.text()); +} + +main(); diff --git a/examples/cloudflare-workers-hono/src/index.ts b/examples/cloudflare-workers-hono/src/index.ts new file mode 100644 index 000000000..8553d7ac8 --- /dev/null +++ b/examples/cloudflare-workers-hono/src/index.ts @@ -0,0 +1,22 @@ +import { createServer } from "@rivetkit/cloudflare-workers"; +import { Hono } from "hono"; +import { registry } from "./registry"; + +const { client, createHandler } = createServer(registry); + +// Setup router +const app = new Hono(); + +// Example HTTP endpoint +app.post("/increment/:name", async (c) => { + const name = c.req.param("name"); + + const counter = client.counter.getOrCreate(name); + const newCount = await counter.increment(1); + + return c.text(`New Count: ${newCount}`); +}); + +const { handler, ActorHandler } = createHandler(app); + +export { handler as default, ActorHandler }; diff --git a/examples/cloudflare-workers-hono/src/registry.ts b/examples/cloudflare-workers-hono/src/registry.ts new file mode 100644 index 000000000..b35af79c3 --- /dev/null +++ b/examples/cloudflare-workers-hono/src/registry.ts @@ -0,0 +1,18 @@ +import { actor, setup } from "@rivetkit/actor"; + +export const counter = actor({ + onAuth: () => { + // Configure auth here + }, + state: { count: 0 }, + actions: { + increment: (c, x: number) => { + c.state.count += x; + return c.state.count; + }, + }, +}); + +export const registry = setup({ + use: { counter }, +}); diff --git a/examples/cloudflare-workers-hono/tsconfig.json b/examples/cloudflare-workers-hono/tsconfig.json new file mode 100644 index 000000000..b8a0a7676 --- /dev/null +++ b/examples/cloudflare-workers-hono/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "esnext", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["esnext"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "esnext", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", + /* Specify type package names to be included without being referenced in a source file. */ + "types": ["@cloudflare/workers-types"], + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/examples/cloudflare-workers-hono/turbo.json b/examples/cloudflare-workers-hono/turbo.json new file mode 100644 index 000000000..95960709b --- /dev/null +++ b/examples/cloudflare-workers-hono/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/examples/cloudflare-workers-hono/wrangler.json b/examples/cloudflare-workers-hono/wrangler.json new file mode 100644 index 000000000..0983455ea --- /dev/null +++ b/examples/cloudflare-workers-hono/wrangler.json @@ -0,0 +1,30 @@ +{ + "name": "rivetkit-cloudflare-workers-example", + "main": "src/index.ts", + "compatibility_date": "2025-01-20", + "compatibility_flags": ["nodejs_compat"], + "migrations": [ + { + "tag": "v1", + "new_classes": ["ActorHandler"] + } + ], + "durable_objects": { + "bindings": [ + { + "name": "ACTOR_DO", + "class_name": "ActorHandler" + } + ] + }, + "kv_namespaces": [ + { + "binding": "ACTOR_KV", + "id": "example_namespace", + "preview_id": "example_namespace_preview" + } + ], + "observability": { + "enabled": true + } +} diff --git a/examples/cloudflare-workers/README.md b/examples/cloudflare-workers/README.md new file mode 100644 index 000000000..bf6cb3c63 --- /dev/null +++ b/examples/cloudflare-workers/README.md @@ -0,0 +1,57 @@ +# Cloudflare Workers for RivetKit + +Example project demonstrating Cloudflare Workers deployment with [RivetKit](https://rivetkit.org). + +[Learn More →](https://github.com/rivet-gg/rivetkit) + +[Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) + +## Getting Started + +### Prerequisites + +- Node.js +- Cloudflare account with Actors enabled +- Wrangler CLI installed globally (`npm install -g wrangler`) + +### Installation + +```sh +git clone https://github.com/rivet-gg/rivetkit +cd rivetkit/examples/cloudflare-workers +npm install +``` + +### Development + +```sh +npm run dev +``` + +This will start the Cloudflare Workers development server locally at http://localhost:8787. + +### Testing the Client + +In a separate terminal, run the client script to interact with your actors: + +```sh +npm run client +``` + +### Deploy to Cloudflare + +First, authenticate with Cloudflare: + +```sh +wrangler login +``` + +Then deploy: + +```sh +npm run deploy +``` + +## License + +Apache 2.0 diff --git a/examples/cloudflare-workers/package.json b/examples/cloudflare-workers/package.json new file mode 100644 index 000000000..e2ae90e3b --- /dev/null +++ b/examples/cloudflare-workers/package.json @@ -0,0 +1,24 @@ +{ + "name": "example-cloudflare-workers", + "version": "0.9.5", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "check-types": "tsc --noEmit", + "client": "tsx scripts/client.ts" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250129.0", + "@types/node": "^22.13.9", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "wrangler": "^4.22.0" + }, + "dependencies": { + "@rivetkit/actor": "workspace:*", + "@rivetkit/cloudflare-workers": "workspace:*" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/cloudflare-workers/scripts/client.ts b/examples/cloudflare-workers/scripts/client.ts new file mode 100644 index 000000000..befbf378c --- /dev/null +++ b/examples/cloudflare-workers/scripts/client.ts @@ -0,0 +1,73 @@ +import { createClient } from "@rivetkit/actor/client"; +import type { registry } from "../src/registry"; + +// Create RivetKit client +const client = createClient( + process.env.RIVETKIT_ENDPOINT ?? "http://localhost:8787", +); + +async function main() { + console.log("🚀 Cloudflare Workers Client Demo"); + + try { + // // Create counter instance + // const counter = client.counter.getOrCreate("demo"); + // const conn = counter.connect(); + // conn.on("foo", (x) => console.log("output", x)); + // + // // Increment counter + // console.log("Incrementing counter 'demo'..."); + // const result1 = await counter.increment(1); + // console.log("New count:", result1); + // + // // Increment again with larger value + // console.log("Incrementing counter 'demo' by 5..."); + // const result2 = await counter.increment(5); + // console.log("New count:", result2); + // + // // Create another counter + // const counter2 = client.counter.getOrCreate("another"); + // console.log("Incrementing counter 'another' by 10..."); + // const result3 = await counter2.increment(10); + // console.log("New count:", result3); + // + // console.log("✅ Demo completed!"); + + const ws = await client.counter.getOrCreate("demo").websocket(); + + console.log("point 1"); + await new Promise((resolve) => { + ws.addEventListener("open", () => resolve(), { once: true }); + }); + + console.log("point 2"); + // Skip welcome message + await new Promise((resolve) => { + ws.addEventListener("message", () => resolve(), { once: true }); + }); + console.log("point 3"); + + // Send and receive echo + const testMessage = { test: "data", timestamp: Date.now() }; + ws.send(JSON.stringify(testMessage)); + console.log("point 4"); + + const echoMessage = await new Promise((resolve) => { + ws.addEventListener( + "message", + (event: any) => { + resolve(JSON.parse(event.data as string)); + }, + { once: true }, + ); + }); + console.log("point 3"); + + ws.close(); + } catch (error) { + console.error("❌ Error:", error); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/examples/cloudflare-workers/src/index.ts b/examples/cloudflare-workers/src/index.ts new file mode 100644 index 000000000..7ba3bf029 --- /dev/null +++ b/examples/cloudflare-workers/src/index.ts @@ -0,0 +1,5 @@ +import { createServerHandler } from "@rivetkit/cloudflare-workers"; +import { registry } from "./registry"; + +const { handler, ActorHandler } = createServerHandler(registry); +export { handler as default, ActorHandler }; diff --git a/examples/cloudflare-workers/src/registry.ts b/examples/cloudflare-workers/src/registry.ts new file mode 100644 index 000000000..72064101f --- /dev/null +++ b/examples/cloudflare-workers/src/registry.ts @@ -0,0 +1,82 @@ +import { actor, setup } from "@rivetkit/actor"; + +export const counter = actor({ + onAuth: () => { + // Configure auth here + }, + state: { count: 0, connectionCount: 0, messageCount: 0 }, + actions: { + increment: (c, x: number) => { + c.state.count += x; + c.broadcast("foo", 1); + return c.state.count; + }, + }, + onWebSocket: (ctx, websocket) => { + // ctx.state.connectionCount = ctx.state.connectionCount + 1; + + // Send welcome message + websocket.send( + JSON.stringify({ + type: "welcome", + connectionCount: ctx.state.connectionCount, + }), + ); + + // Echo messages back + websocket.addEventListener("message", (event: any) => { + //ctx.state.messageCount++; + + const data = event.data; + if (typeof data === "string") { + try { + const parsed = JSON.parse(data); + if (parsed.type === "ping") { + websocket.send( + JSON.stringify({ + type: "pong", + timestamp: Date.now(), + }), + ); + } else if (parsed.type === "getStats") { + websocket.send( + JSON.stringify({ + type: "stats", + connectionCount: ctx.state.connectionCount, + messageCount: ctx.state.messageCount, + }), + ); + } else if (parsed.type === "getAuthData") { + // Auth data is not directly available in raw WebSocket handler + // Send a message indicating this limitation + websocket.send( + JSON.stringify({ + type: "authData", + authData: null, + message: "Auth data not available in raw WebSocket handler", + }), + ); + } else { + // Echo back + websocket.send(data); + } + } catch { + // If not JSON, just echo it back + websocket.send(data); + } + } else { + // Echo binary data + websocket.send(data); + } + }); + + // Handle close + websocket.addEventListener("close", () => { + // ctx.state.connectionCount = ctx.state.connectionCount - 1; + }); + }, +}); + +export const registry = setup({ + use: { counter }, +}); diff --git a/examples/cloudflare-workers/tsconfig.json b/examples/cloudflare-workers/tsconfig.json new file mode 100644 index 000000000..b8a0a7676 --- /dev/null +++ b/examples/cloudflare-workers/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "esnext", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["esnext"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "esnext", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", + /* Specify type package names to be included without being referenced in a source file. */ + "types": ["@cloudflare/workers-types"], + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/examples/cloudflare-workers/turbo.json b/examples/cloudflare-workers/turbo.json new file mode 100644 index 000000000..95960709b --- /dev/null +++ b/examples/cloudflare-workers/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/examples/cloudflare-workers/wrangler.json b/examples/cloudflare-workers/wrangler.json new file mode 100644 index 000000000..0983455ea --- /dev/null +++ b/examples/cloudflare-workers/wrangler.json @@ -0,0 +1,30 @@ +{ + "name": "rivetkit-cloudflare-workers-example", + "main": "src/index.ts", + "compatibility_date": "2025-01-20", + "compatibility_flags": ["nodejs_compat"], + "migrations": [ + { + "tag": "v1", + "new_classes": ["ActorHandler"] + } + ], + "durable_objects": { + "bindings": [ + { + "name": "ACTOR_DO", + "class_name": "ActorHandler" + } + ] + }, + "kv_namespaces": [ + { + "binding": "ACTOR_KV", + "id": "example_namespace", + "preview_id": "example_namespace_preview" + } + ], + "observability": { + "enabled": true + } +} diff --git a/examples/counter/.gitignore b/examples/counter/.gitignore new file mode 100644 index 000000000..79b7a1192 --- /dev/null +++ b/examples/counter/.gitignore @@ -0,0 +1,2 @@ +.actorcore +node_modules \ No newline at end of file diff --git a/examples/counter/README.md b/examples/counter/README.md new file mode 100644 index 000000000..cac72debf --- /dev/null +++ b/examples/counter/README.md @@ -0,0 +1,37 @@ +# Counter for RivetKit + +Example project demonstrating basic actor state management and RPC calls with [RivetKit](https://rivetkit.org). + +[Learn More →](https://github.com/rivet-gg/rivetkit) + +[Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) + +## Getting Started + +### Prerequisites + +- Node.js + +### Installation + +```sh +git clone https://github.com/rivet-gg/rivetkit +cd rivetkit/examples/counter +npm install +``` + +### Development + +```sh +npm run dev +``` + +Run the connect script to interact with the counter: + +```sh +tsx scripts/connect.ts +``` + +## License + +Apache 2.0 \ No newline at end of file diff --git a/examples/counter/package.json b/examples/counter/package.json new file mode 100644 index 000000000..a9e899ede --- /dev/null +++ b/examples/counter/package.json @@ -0,0 +1,19 @@ +{ + "name": "counter", + "version": "0.9.5", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx src/server.ts", + "check-types": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@rivetkit/actor": "workspace:*", + "@types/node": "^22.13.9", + "tsx": "^3.12.7", + "typescript": "^5.7.3", + "vitest": "^3.1.1" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/counter/scripts/connect.ts b/examples/counter/scripts/connect.ts new file mode 100644 index 000000000..c690781ba --- /dev/null +++ b/examples/counter/scripts/connect.ts @@ -0,0 +1,24 @@ +import { createClient } from "@rivetkit/actor/client"; +import type { Registry } from "../src/registry"; + +async function main() { + const client = createClient( + process.env.ENDPOINT ?? "http://127.0.0.1:8080", + ); + + const counter = await client.counter.getOrCreate().connect(); + + counter.on("newCount", (count: number) => console.log("Event:", count)); + + for (let i = 0; i < 5; i++) { + const out = await counter.increment(5); + console.log("RPC:", out); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + await new Promise((resolve) => setTimeout(resolve, 10000)); + await counter.dispose(); +} + +main(); diff --git a/examples/counter/src/registry.ts b/examples/counter/src/registry.ts new file mode 100644 index 000000000..07ee4d22f --- /dev/null +++ b/examples/counter/src/registry.ts @@ -0,0 +1,26 @@ +import { actor, setup } from "@rivetkit/actor"; + +const counter = actor({ + state: { + count: 0, + }, + onAuth: () => { + return true; + }, + actions: { + increment: (c, x: number) => { + c.state.count += x; + c.broadcast("newCount", c.state.count); + return c.state.count; + }, + getCount: (c) => { + return c.state.count; + }, + }, +}); + +export const registry = setup({ + use: { counter }, +}); + +export type Registry = typeof registry; diff --git a/examples/counter/src/server.ts b/examples/counter/src/server.ts new file mode 100644 index 000000000..11163905a --- /dev/null +++ b/examples/counter/src/server.ts @@ -0,0 +1,3 @@ +import { registry } from "./registry"; + +registry.runServer(); diff --git a/examples/counter/tests/counter.test.ts b/examples/counter/tests/counter.test.ts new file mode 100644 index 000000000..89034864f --- /dev/null +++ b/examples/counter/tests/counter.test.ts @@ -0,0 +1,35 @@ +import { setupTest } from "@rivetkit/actor/test"; +import { expect, test } from "vitest"; +import { registry } from "../src/registry"; + +test("it should count", async (test) => { + const { client } = await setupTest(test, registry); + const counter = client.counter.getOrCreate().connect(); + + // Test initial count + expect(await counter.getCount()).toBe(0); + + // Test event emission + let eventCount = -1; + counter.on("newCount", (count: number) => { + eventCount = count; + }); + + // Test increment + const incrementAmount = 5; + const result = await counter.increment(incrementAmount); + expect(result).toBe(incrementAmount); + + // Verify event was emitted with correct count + expect(eventCount).toBe(incrementAmount); + + // Test multiple increments + for (let i = 1; i <= 3; i++) { + const newCount = await counter.increment(incrementAmount); + expect(newCount).toBe(incrementAmount * (i + 1)); + expect(eventCount).toBe(incrementAmount * (i + 1)); + } + + // Verify final count + expect(await counter.getCount()).toBe(incrementAmount * 4); +}); diff --git a/examples/counter/tsconfig.json b/examples/counter/tsconfig.json new file mode 100644 index 000000000..df33a97c3 --- /dev/null +++ b/examples/counter/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "esnext", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["esnext"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "esnext", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", + /* Specify type package names to be included without being referenced in a source file. */ + "types": ["node"], + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"] +} diff --git a/examples/counter/turbo.json b/examples/counter/turbo.json new file mode 100644 index 000000000..95960709b --- /dev/null +++ b/examples/counter/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/examples/crdt/README.md b/examples/crdt/README.md new file mode 100644 index 000000000..e927175d6 --- /dev/null +++ b/examples/crdt/README.md @@ -0,0 +1,64 @@ +# CRDT Collaborative Editor for RivetKit + +Example project demonstrating real-time collaborative editing using Conflict-free Replicated Data Types (CRDTs) with [RivetKit](https://rivetkit.org). + +[Learn More →](https://github.com/rivet-gg/rivetkit) + +[Discord](https://rivet.gg/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-gg/rivetkit/issues) + +## Getting Started + +### Prerequisites + +- Node.js 18+ + +### Installation + +```sh +git clone https://github.com/rivet-gg/rivetkit +cd rivetkit/examples/crdt +npm install +``` + +### Development + +```sh +npm run dev +``` + +Open your browser to `http://localhost:3000` + +## Features + +- **Real-time Collaborative Editing**: Multiple users can edit the same document simultaneously +- **Conflict Resolution**: Uses Yjs CRDTs to automatically resolve editing conflicts +- **Persistent State**: Document changes are automatically persisted +- **Multiple Documents**: Switch between different collaborative documents +- **Live Connection Status**: See when you're connected to the collaboration server + +## How it works + +This example demonstrates how to build a collaborative editor using: + +1. **Yjs**: A high-performance CRDT implementation for building collaborative applications +2. **RivetKit Actors**: Manage document state and synchronize changes between clients +3. **Real-time Updates**: Use RivetKit's event system for instant synchronization +4. **Conflict-free Merging**: Yjs automatically handles concurrent edits without conflicts + +## Usage + +1. Start the development server +2. Open multiple browser tabs to `http://localhost:3000` +3. Start typing in any tab - changes will appear in real-time across all tabs +4. Try editing the same text simultaneously to see conflict resolution in action +5. Switch between different documents using the document ID field + +## Architecture + +- **Backend**: RivetKit actor that manages Yjs document state and broadcasts updates +- **Frontend**: React application with Yjs integration for local document management +- **Synchronization**: Binary diffs are sent between clients for efficient updates + +## License + +Apache 2.0 \ No newline at end of file diff --git a/examples/crdt/package.json b/examples/crdt/package.json new file mode 100644 index 000000000..f611a2bb5 --- /dev/null +++ b/examples/crdt/package.json @@ -0,0 +1,33 @@ +{ + "name": "example-crdt", + "version": "0.9.5", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev:backend": "tsx --watch src/backend/server.ts", + "dev:frontend": "vite", + "build": "vite build", + "check-types": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "concurrently": "^8.2.2", + "@rivetkit/actor": "workspace:*", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "vite": "^5.0.0", + "vitest": "^3.1.1" + }, + "dependencies": { + "@rivetkit/react": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "yjs": "^13.6.20" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/crdt/src/backend/registry.ts b/examples/crdt/src/backend/registry.ts new file mode 100644 index 000000000..30d7ae811 --- /dev/null +++ b/examples/crdt/src/backend/registry.ts @@ -0,0 +1,73 @@ +import { actor, setup } from "@rivetkit/actor"; +import * as Y from "yjs"; +import { applyUpdate, encodeStateAsUpdate } from "yjs"; + +export const yjsDocument = actor({ + onAuth: () => {}, + // Persistent state that survives restarts: https://rivet.gg/docs/actors/state + state: { + docData: "", // Base64 encoded Yjs document + lastModified: 0, + }, + + createVars: () => ({ + doc: new Y.Doc(), + }), + + onStart: (c) => { + if (c.state.docData) { + const binary = atob(c.state.docData); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + applyUpdate(c.vars.doc, bytes); + } + }, + + // Handle client connections: https://rivet.gg/docs/actors/connection-lifecycle + onConnect: (c, conn) => { + const update = encodeStateAsUpdate(c.vars.doc); + const base64 = bufferToBase64(update); + conn.send("initialState", { update: base64 }); + }, + + actions: { + // Callable functions from clients: https://rivet.gg/docs/actors/actions + applyUpdate: (c, updateBase64: string) => { + const binary = atob(updateBase64); + const update = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + update[i] = binary.charCodeAt(i); + } + + applyUpdate(c.vars.doc, update); + + const fullState = encodeStateAsUpdate(c.vars.doc); + // State changes are automatically persisted + c.state.docData = bufferToBase64(fullState); + c.state.lastModified = Date.now(); + + // Send events to all connected clients: https://rivet.gg/docs/actors/events + c.broadcast("update", { update: updateBase64 }); + }, + + getState: (c) => ({ + docData: c.state.docData, + lastModified: c.state.lastModified, + }), + }, +}); + +function bufferToBase64(buffer: Uint8Array): string { + let binary = ""; + for (let i = 0; i < buffer.byteLength; i++) { + binary += String.fromCharCode(buffer[i]); + } + return btoa(binary); +} + +// Register actors for use: https://rivet.gg/docs/setup +export const registry = setup({ + use: { yjsDocument }, +}); diff --git a/examples/crdt/src/backend/server.ts b/examples/crdt/src/backend/server.ts new file mode 100644 index 000000000..fd02aa970 --- /dev/null +++ b/examples/crdt/src/backend/server.ts @@ -0,0 +1,7 @@ +import { registry } from "./registry"; + +registry.runServer({ + cors: { + origin: "*", + }, +}); diff --git a/examples/crdt/src/frontend/App.tsx b/examples/crdt/src/frontend/App.tsx new file mode 100644 index 000000000..bca29cd54 --- /dev/null +++ b/examples/crdt/src/frontend/App.tsx @@ -0,0 +1,195 @@ +import { createClient, createRivetKit } from "@rivetkit/react"; +import { useEffect, useRef, useState } from "react"; +import * as Y from "yjs"; +import { applyUpdate, encodeStateAsUpdate } from "yjs"; +import type { registry } from "../backend/registry"; + +const client = createClient("http://localhost:8080"); +const { useActor } = createRivetKit(client); + +function YjsEditor({ documentId }: { documentId: string }) { + const yjsDocument = useActor({ + name: "yjsDocument", + key: [documentId], + }); + + const [isLoading, setIsLoading] = useState(true); + const [text, setText] = useState(""); + + const yDocRef = useRef(null); + const updatingFromServer = useRef(false); + const updatingFromLocal = useRef(false); + const observationInitialized = useRef(false); + + useEffect(() => { + const yDoc = new Y.Doc(); + yDocRef.current = yDoc; + setIsLoading(false); + + return () => { + yDoc.destroy(); + }; + }, [yjsDocument.connection]); + + useEffect(() => { + const yDoc = yDocRef.current; + if (!yDoc || observationInitialized.current) return; + + const yText = yDoc.getText("content"); + + yText.observe(() => { + if (!updatingFromServer.current) { + setText(yText.toString()); + + if (yjsDocument.connection && !updatingFromLocal.current) { + updatingFromLocal.current = true; + + const update = encodeStateAsUpdate(yDoc); + const base64 = bufferToBase64(update); + yjsDocument.connection.applyUpdate(base64).finally(() => { + updatingFromLocal.current = false; + }); + } + } + }); + + observationInitialized.current = true; + }, [yjsDocument.connection]); + + yjsDocument.useEvent("initialState", ({ update }: { update: string }) => { + const yDoc = yDocRef.current; + if (!yDoc) return; + + updatingFromServer.current = true; + + try { + const binary = atob(update); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + applyUpdate(yDoc, bytes); + + const yText = yDoc.getText("content"); + setText(yText.toString()); + } catch (error) { + console.error("Error applying initial update:", error); + } finally { + updatingFromServer.current = false; + } + }); + + yjsDocument.useEvent("update", ({ update }: { update: string }) => { + const yDoc = yDocRef.current; + if (!yDoc) return; + + updatingFromServer.current = true; + + try { + const binary = atob(update); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + applyUpdate(yDoc, bytes); + + const yText = yDoc.getText("content"); + setText(yText.toString()); + } catch (error) { + console.error("Error applying update:", error); + } finally { + updatingFromServer.current = false; + } + }); + + const handleTextChange = (e: React.ChangeEvent) => { + if (!yDocRef.current) return; + + const newText = e.target.value; + const yText = yDocRef.current.getText("content"); + + if (newText !== yText.toString()) { + updatingFromLocal.current = true; + + yDocRef.current.transact(() => { + yText.delete(0, yText.length); + yText.insert(0, newText); + }); + + updatingFromLocal.current = false; + } + }; + + if (isLoading) { + return
Loading collaborative document...
; + } + + return ( +
+
+

Document: {documentId}

+
+ {yjsDocument.connection ? 'Connected' : 'Disconnected'} +
+
+