diff --git a/examples/freestyle/.env.sample b/examples/freestyle/.env.sample new file mode 100644 index 000000000..16f9f1cfe --- /dev/null +++ b/examples/freestyle/.env.sample @@ -0,0 +1,14 @@ +FREESTYLE_API_KEY="" +FREESTYLE_DOMAIN="example.style.dev" +RIVET_TOKEN="" + +# To enable serverless +RIVET_ENDPOINT="" +RIVET_RUNNER_KIND="serverless" + +# For accessing actor (not relevant if using self-hosted Rivet Engine) +RIVET_PUBLISHABLE_TOKEN="" # default: "" + +# Optional variables with defaults: +RIVET_NAMESPACE="default" # default: "default" +RIVET_RUNNER_NAME="freestyle-runner" # default: "freestyle-runner" \ No newline at end of file diff --git a/examples/freestyle/.gitignore b/examples/freestyle/.gitignore new file mode 100644 index 000000000..79b7a1192 --- /dev/null +++ b/examples/freestyle/.gitignore @@ -0,0 +1,2 @@ +.actorcore +node_modules \ No newline at end of file diff --git a/examples/freestyle/README.md b/examples/freestyle/README.md new file mode 100644 index 000000000..83e9a2b4c --- /dev/null +++ b/examples/freestyle/README.md @@ -0,0 +1,65 @@ +# Freestyle Deployment for RivetKit + +Example project demonstrating serverless deployment of RivetKit actors to [Freestyle](https://freestyle.sh) with [RivetKit](https://rivetkit.org). + +[Learn More →](https://github.com/rivet-dev/rivetkit) + +[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues) + + +## What is this? + +Freestyle is unique from other providers since it is built to deploy untrusted AI-generated or user-generated code. This enables your application to deploy vibe-coded or user-provided backends on Rivet and Freestyle. This example showcases a real-time stateful chat app that can be deployed to FreeStyle's [Web Deployment](https://docs.freestyle.sh/web/overview) platform. + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- Deno (for development) + +**Note**: Deno is required since Freestyle uses Deno for their Web Deployments under the hood + +### Installation + +```sh +git clone https://github.com/rivet-dev/rivetkit +cd rivetkit/examples/freestyle +pnpm install +``` + +### Development + +```sh +pnpm run dev +``` + +Open your browser to `http://localhost:5173` to see the application. + +```sh +RIVET_RUNNER_KIND=serverless VITE_RIVET_ENDPOINT="$RIVET_ENDPOINT" pnpm run dev +``` + +### Deploy to Freestyle + +```sh +# Set env vars +export FREESTYLE_DOMAIN="my-domain.style.dev" # Set this to any unique *.style.dev domain +export FREESTYLE_API_KEY="XXXX" # See https://admin.freestyle.sh/dashboard/api-tokens +export RIVET_ENDPOINT="http://api.rivet.gg" +export RIVET_NAMESPACE="XXXX" # Creates new namespace if does not exist +export RIVET_TOKEN="XXXX" # Rivet Service token +export RIVET_PUBLISHABLE_TOKEN="XXXX" # For connecting to Rivet Actors + +pnpm run freestyle:deploy +``` + +Open your browser to your Freestyle domain to see your application connect to Rivet deployed on Freestyle. + +If self-hosting Rivet: +1. **Important**: `RIVET_ENDPOINT` must be public to the internet. +2. `RIVET_PUBLISHABLE_TOKEN` can be kept empty. + +## License + +Apache 2.0 diff --git a/examples/freestyle/deno.json b/examples/freestyle/deno.json new file mode 100644 index 000000000..b67c58958 --- /dev/null +++ b/examples/freestyle/deno.json @@ -0,0 +1,11 @@ +{ + "imports": { + "os": "node:os", + "path": "node:path", + "fs": "node:fs", + "fs/promises": "node:fs/promises", + "crypto": "node:crypto", + "rivetkit": "npm:rivetkit", + "hono": "npm:hono" + } +} diff --git a/examples/freestyle/package.json b/examples/freestyle/package.json new file mode 100644 index 000000000..e2b0d519f --- /dev/null +++ b/examples/freestyle/package.json @@ -0,0 +1,38 @@ +{ + "name": "example-freestyle", + "version": "2.0.7-rc.1", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently \"pnpm run dev:backend\" \"pnpm run dev:frontend\"", + "dev:backend": "deno run --watch --allow-all src/backend/server.ts", + "dev:frontend": "vite", + "freestyle:deploy": "tsx scripts/freestyle-deploy.ts", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@rivetkit/engine-api-full": "^25.7.2", + "@rivetkit/react": "workspace:*", + "@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", + "dotenv": "^17.2.2", + "freestyle-sandboxes": "^0.0.95", + "prompts": "^2.4.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tsup": "^8.5.0", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "vite": "^5.0.0", + "vitest": "^3.1.1" + }, + "dependencies": { + "hono": "4.9.8", + "rivetkit": "workspace:*" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/freestyle/scripts/freestyle-deploy.ts b/examples/freestyle/scripts/freestyle-deploy.ts new file mode 100644 index 000000000..0f3cf23fd --- /dev/null +++ b/examples/freestyle/scripts/freestyle-deploy.ts @@ -0,0 +1,153 @@ +import { type Rivet, RivetClient } from "@rivetkit/engine-api-full"; +import { execSync } from "child_process"; +import dotenv from "dotenv"; +import { FreestyleSandboxes } from "freestyle-sandboxes"; +import { prepareDirForDeploymentSync } from "freestyle-sandboxes/utils"; +import { readFileSync } from "fs"; + +dotenv.config({ path: new URL("../.env", import.meta.url).pathname }); + +const FREESTYLE_DOMAIN = getEnv("FREESTYLE_DOMAIN"); +const FREESTYLE_API_KEY = getEnv("FREESTYLE_API_KEY"); +const RIVET_ENDPOINT = getEnv("RIVET_ENDPOINT"); +const RIVET_TOKEN = getEnv("RIVET_TOKEN"); +const RIVET_NAMESPACE_NAME = getEnv("RIVET_NAMESPACE"); +const RIVET_RUNNER_NAME = "freestyle"; + +function getEnv(key: string): string { + const value = process.env[key]; + if (typeof value === "undefined") { + throw new Error(`Missing env var: ${key}`); + } + return value; +} + +const rivet = new RivetClient({ + environment: RIVET_ENDPOINT, + token: RIVET_TOKEN, +}); + +const freestyle = new FreestyleSandboxes({ + apiKey: FREESTYLE_API_KEY, +}); + +async function main() { + const namespace = await getOrCreateNamespace({ + displayName: RIVET_NAMESPACE_NAME, + name: RIVET_NAMESPACE_NAME, + }); + console.log("Got namespace " + namespace.name); + + runBuildSteps(); + + await deployToFreestyle(); + console.log("Deployed to freestyle"); + + await updateRunnerConfig(namespace); + console.log("Updated runner config"); + + console.log("🎉 Deployment complete! 🎉"); + console.log( + "Visit https://" + + FREESTYLE_DOMAIN + + "/ to see your frontend, which is connected to Rivet", + ); +} + +function runBuildSteps() { + console.log("Running build steps..."); + + console.log("- Running vite build"); + execSync("vite build", { + stdio: "inherit", + env: { + ...process.env, + VITE_RIVET_ENDPOINT: RIVET_ENDPOINT, + VITE_RIVET_NAMESPACE: RIVET_NAMESPACE_NAME, + VITE_RIVET_RUNNER_NAME: RIVET_RUNNER_NAME, + }, + }); + + console.log("- Running tsup"); + execSync("tsup", { + stdio: "inherit", + }); + + console.log("Build complete!"); +} + +async function getOrCreateNamespace({ + name, + displayName, +}: { + name: string; + displayName: string; +}): Promise { + console.log("- Checking for existing " + name + " namespace"); + const { namespaces } = await rivet.namespaces.list({ + limit: 32, + }); + const existing = namespaces.find((ns) => ns.name === name); + if (existing) { + console.log("- Found existing namespace " + name); + return existing; + } + console.log("- Creating namespace " + name); + const { namespace } = await rivet.namespaces.create({ + displayName, + name, + }); + return namespace; +} + +async function updateRunnerConfig(namespace: Rivet.Namespace) { + console.log("- Updating runner config for " + RIVET_RUNNER_NAME); + await rivet.runnerConfigs.upsert(RIVET_RUNNER_NAME, { + serverless: { + url: "https://" + FREESTYLE_DOMAIN + "/api/start", + headers: {}, + runnersMargin: 1, + minRunners: 1, + maxRunners: 1, + slotsPerRunner: 1, + requestLifespan: 60 * 4 + 30, + }, + namespace: namespace.name, + }); +} + +async function deployToFreestyle() { + console.log("- Deploying to freestyle at https://" + FREESTYLE_DOMAIN); + + const buildDir = prepareDirForDeploymentSync( + new URL("../dist", import.meta.url).pathname, + ); + if (buildDir.kind === "files") { + buildDir.files["deno.json"] = { + // Fix imports for Deno + content: readFileSync( + new URL("../deno.json", import.meta.url).pathname, + "utf-8", + ), + encoding: "utf-8", + }; + } else { + throw new Error("Expected buildDir to be files"); + } + const res = await freestyle.deployWeb(buildDir, { + envVars: { + LOG_LEVEL: "debug", + FREESTYLE_ENDPOINT: `https://${FREESTYLE_DOMAIN}`, + RIVET_ENDPOINT, + RIVET_RUNNER_KIND: "serverless", + }, + timeout: 60 * 5, + entrypoint: "server.cjs", + domains: [FREESTYLE_DOMAIN], + build: false, + }); + + console.log("Deployment id=" + res.deploymentId); +} + +main(); diff --git a/examples/freestyle/src/backend/registry.ts b/examples/freestyle/src/backend/registry.ts new file mode 100644 index 000000000..34e333eb1 --- /dev/null +++ b/examples/freestyle/src/backend/registry.ts @@ -0,0 +1,29 @@ +import { actor, setup } from "rivetkit"; + +export type Message = { sender: string; text: string; timestamp: number }; + +export const chatRoom = actor({ + // Persistent state that survives restarts: https://rivet.dev/docs/actors/state + state: { + messages: [] as Message[], + }, + + actions: { + // Callable functions from clients: https://rivet.dev/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.dev/docs/actors/events + c.broadcast("newMessage", message); + return message; + }, + + getHistory: (c) => c.state.messages, + }, +}); + +// Register actors for use: https://rivet.dev/docs/setup +export const registry = setup({ + use: { chatRoom }, +}); diff --git a/examples/freestyle/src/backend/server.ts b/examples/freestyle/src/backend/server.ts new file mode 100644 index 000000000..b47d1d896 --- /dev/null +++ b/examples/freestyle/src/backend/server.ts @@ -0,0 +1,28 @@ +import { Hono } from "hono"; +import { serveStatic, upgradeWebSocket } from "hono/deno"; +import { registry } from "./registry"; + +const serverOutput = registry.start({ + inspector: { + enabled: true, + }, + disableDefaultServer: true, + basePath: "/api", + getUpgradeWebSocket: () => upgradeWebSocket, + overrideServerAddress: `${process.env.FREESTYLE_ENDPOINT ?? "http://localhost:8080"}/api`, + cors: { + origin: process.env.FREESTYLE_ENDPOINT ?? "http://localhost:5173", + credentials: true, + }, +}); + +const app = new Hono(); +app.use("/api/*", async (c) => { + return await serverOutput.fetch(c.req.raw); +}); +app.use("*", serveStatic({ root: "./public" })); + +// Under the hood, Freestyle uses Deno +// for their Web Deploy instances +// @ts-ignore +Deno.serve({ port: 8080 }, app.fetch); diff --git a/examples/freestyle/src/frontend/App.tsx b/examples/freestyle/src/frontend/App.tsx new file mode 100644 index 000000000..2d3e91c2d --- /dev/null +++ b/examples/freestyle/src/frontend/App.tsx @@ -0,0 +1,104 @@ +import { createClient, createRivetKit } from "@rivetkit/react"; +import { useEffect, useState } from "react"; +import type { Message, registry } from "../backend/registry"; + +const client = createClient({ + endpoint: import.meta.env.VITE_RIVET_ENDPOINT ?? "http://localhost:8080/api", + namespace: import.meta.env.VITE_RIVET_NAMESPACE, + runnerName: import.meta.env.VITE_RIVET_RUNNER_NAME ?? "freestyle-runner", +}); +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} + /> + +
+
+ ); +} diff --git a/examples/freestyle/src/frontend/index.html b/examples/freestyle/src/frontend/index.html new file mode 100644 index 000000000..91526270f --- /dev/null +++ b/examples/freestyle/src/frontend/index.html @@ -0,0 +1,113 @@ + + + + + + Chat Room Example + + + +
+ + + \ No newline at end of file diff --git a/examples/freestyle/src/frontend/main.tsx b/examples/freestyle/src/frontend/main.tsx new file mode 100644 index 000000000..bd39f29ee --- /dev/null +++ b/examples/freestyle/src/frontend/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Root element not found"); + +createRoot(root).render( + + + +); diff --git a/examples/freestyle/tsconfig.json b/examples/freestyle/tsconfig.json new file mode 100644 index 000000000..757a13a94 --- /dev/null +++ b/examples/freestyle/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/freestyle/tsup.config.js b/examples/freestyle/tsup.config.js new file mode 100644 index 000000000..c8b23958c --- /dev/null +++ b/examples/freestyle/tsup.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/backend/server.ts"], + outDir: "dist", + // Vite build is in dist/public/* so we don't want to clean it + clean: false, + bundle: true, + platform: "node", + target: "deno2.5", + external: [], + // Include rivetkit in the bundle since it's local + // Note: this makes bundle much larger and can be + // removed if using a version of rivetkit from npm + noExternal: ["rivetkit", "hono"], +}); diff --git a/examples/freestyle/turbo.json b/examples/freestyle/turbo.json new file mode 100644 index 000000000..95960709b --- /dev/null +++ b/examples/freestyle/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/examples/freestyle/vite.config.ts b/examples/freestyle/vite.config.ts new file mode 100644 index 000000000..6495f1f87 --- /dev/null +++ b/examples/freestyle/vite.config.ts @@ -0,0 +1,15 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + root: "src/frontend", + build: { + outDir: "../../dist/public", + }, + envDir: ".", + server: { + host: "0.0.0.0", + port: 5173, + }, +}); diff --git a/examples/freestyle/vitest.config.ts b/examples/freestyle/vitest.config.ts new file mode 100644 index 000000000..5bdee0020 --- /dev/null +++ b/examples/freestyle/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +});