├── e2e ├── templates │ └── basic │ │ ├── README.md │ │ ├── app │ │ ├── root.css │ │ ├── entry.server.tsx │ │ ├── routes │ │ │ └── index.tsx │ │ └── root.tsx │ │ ├── .gitignore │ │ ├── tailwind.config.js │ │ ├── vite.config.ts │ │ ├── wrangler.jsonc │ │ ├── tsconfig.json │ │ └── package.json ├── fixture │ ├── util.ts │ └── index.ts ├── hmr.spec.ts ├── basic.spec.ts ├── error-handling.spec.ts ├── actors.spec.ts └── hono-handler.spec.ts ├── pnpm-workspace.yaml ├── packages ├── vite │ ├── src │ │ ├── entrypoints │ │ │ ├── entry.ssr.tsx │ │ │ └── entry.browser.tsx │ │ ├── routing │ │ │ ├── index.ts │ │ │ └── fs-routes.ts │ │ ├── virtual-module.ts │ │ ├── util.ts │ │ ├── vite-exec.ts │ │ ├── config.ts │ │ ├── plugins │ │ │ ├── route-reload.ts │ │ │ ├── config.ts │ │ │ ├── routes.ts │ │ │ ├── isolation.ts │ │ │ └── preserve-class-names.ts │ │ └── index.ts │ ├── tsconfig.json │ └── package.json ├── core │ ├── src │ │ ├── actor.tsx │ │ ├── index.tsx │ │ ├── internal-context.ts │ │ ├── router.ts │ │ ├── util.ts │ │ ├── modules.d.ts │ │ ├── hono.ts │ │ ├── error-handling │ │ │ └── browser.tsx │ │ ├── ssr.tsx │ │ ├── client.tsx │ │ └── server.tsx │ ├── tsconfig.json │ └── package.json ├── create-orange │ ├── tsconfig.json │ ├── src │ │ ├── replace.ts │ │ ├── command.ts │ │ └── index.ts │ └── package.json ├── actors │ ├── tsconfig.json │ ├── package.json │ └── src │ │ ├── client.tsx │ │ ├── observed.tsx │ │ └── index.tsx └── cli │ ├── tsconfig.json │ ├── src │ ├── index.ts │ ├── exec.ts │ ├── account.ts │ ├── wrangler.ts │ ├── cf-auth.ts │ ├── commands │ │ ├── provision │ │ │ ├── kv.ts │ │ │ ├── durable-objects.ts │ │ │ ├── object-storage.ts │ │ │ ├── index.ts │ │ │ ├── sqlite.ts │ │ │ └── postgres.ts │ │ └── types.ts │ └── prompts.ts │ └── package.json ├── .npmrc ├── .gitignore ├── scripts └── version-for-prerelease.ts ├── package.json ├── biome.json ├── LICENSE ├── playwright.config.ts ├── README.md ├── .github └── workflows │ ├── release.yaml │ ├── e2e.yaml │ └── prerelease.yaml └── tsconfig.json /e2e/templates/basic/README.md: -------------------------------------------------------------------------------- 1 | # orange-template -------------------------------------------------------------------------------- /e2e/templates/basic/app/root.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'e2e/templates/*' -------------------------------------------------------------------------------- /e2e/templates/basic/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules 3 | .wrangler 4 | .dev.vars 5 | .env 6 | .types -------------------------------------------------------------------------------- /packages/vite/src/entrypoints/entry.ssr.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | export * from "@orange-js/orange/ssr"; 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Bug with resolving dependencies with PNPM + Vite, 2 | # this will be fixed in future versions of Orange 3 | shamefully-hoist = true -------------------------------------------------------------------------------- /packages/vite/src/entrypoints/entry.browser.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { main } from "@orange-js/orange/client"; 3 | 4 | await main(); 5 | -------------------------------------------------------------------------------- /packages/vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | } 6 | } -------------------------------------------------------------------------------- /e2e/templates/basic/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { app } from "@orange-js/orange/server"; 2 | import { Root } from "./root"; 3 | 4 | export default app(Root); 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | packages/*/node_modules/ 3 | packages/*/dist/ 4 | 5 | # Playwright 6 | /test-results/ 7 | /playwright-report/ 8 | /blob-report/ 9 | /playwright/.cache/ 10 | 11 | .test-fixtures -------------------------------------------------------------------------------- /packages/core/src/actor.tsx: -------------------------------------------------------------------------------- 1 | import type { Actor } from "@orange-js/actors"; 2 | 3 | export function isActor(value: unknown): value is Actor { 4 | // @ts-ignore 5 | return typeof value === "function" && value["__orangeIsActor"] === true; 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/src/index.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | export type CloudflareEnv = Env; 3 | 4 | // biome-ignore lint/complexity/noBannedTypes: 5 | export type ContextFrom {}> = Awaited>; 6 | 7 | export interface Context {} 8 | -------------------------------------------------------------------------------- /packages/vite/src/routing/index.ts: -------------------------------------------------------------------------------- 1 | export type Route = { 2 | // The pattern is the pathname of the route, should follow URLPattern syntax 3 | pattern: string; 4 | // The file is the path to the file that contains the route 5 | file: string; 6 | }; 7 | -------------------------------------------------------------------------------- /packages/create-orange/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "types": ["@types/node"], 6 | "module": "CommonJS", 7 | "moduleResolution": "node" 8 | } 9 | } -------------------------------------------------------------------------------- /e2e/templates/basic/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./app/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | 13 | -------------------------------------------------------------------------------- /packages/core/src/internal-context.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from "async_hooks"; 2 | 3 | export type InternalContext = { 4 | request: Request; 5 | params: Record; 6 | }; 7 | 8 | export const internalContext = new AsyncLocalStorage(); 9 | -------------------------------------------------------------------------------- /e2e/templates/basic/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | export default async function Home() { 2 | return ( 3 |
4 |

Hello World 🍊

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "vite/client", 6 | "@vitejs/plugin-rsc/types", 7 | "@cloudflare/workers-types", 8 | "@types/node" 9 | ], 10 | "outDir": "dist" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/actors/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "vite/client", 6 | "@vitejs/plugin-rsc/types", 7 | "@cloudflare/workers-types", 8 | "@types/node" 9 | ], 10 | "outDir": "dist" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src", 6 | "types": ["node"], 7 | "target": "ES2024", 8 | "lib": ["ES2024"], 9 | "declaration": true 10 | }, 11 | "include": ["src/**/*"] 12 | } -------------------------------------------------------------------------------- /e2e/fixture/util.ts: -------------------------------------------------------------------------------- 1 | export class DisposeScope { 2 | #tasks: Array<() => void | Promise> = []; 3 | 4 | register(task: () => void) { 5 | this.#tasks.push(task); 6 | } 7 | 8 | async [Symbol.asyncDispose]() { 9 | for (const task of this.#tasks) { 10 | task(); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /packages/core/src/router.ts: -------------------------------------------------------------------------------- 1 | export type Route = { 2 | pattern: URLPattern; 3 | }; 4 | 5 | export function router( 6 | routes: T[], 7 | ): (request: Request) => T | undefined { 8 | return (request: Request) => { 9 | const url = new URL(request.url); 10 | return routes.find((route) => route.pattern.test(url)); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /packages/core/src/util.ts: -------------------------------------------------------------------------------- 1 | export function unreachable(): never { 2 | throw new Error("unreachable"); 3 | } 4 | 5 | export function bail(message: string): never { 6 | throw new Error(message); 7 | } 8 | 9 | export function assert( 10 | whatever: T, 11 | message = "assertion failed", 12 | ): asserts whatever { 13 | if (!whatever) { 14 | throw new Error(message); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /e2e/templates/basic/vite.config.ts: -------------------------------------------------------------------------------- 1 | import orange from "@orange-js/vite"; 2 | import tsconfigPaths from "vite-tsconfig-paths"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | import { defineConfig } from "vite"; 5 | 6 | export default defineConfig({ 7 | // @ts-expect-error - vite type mismatch 8 | plugins: [orange(), tsconfigPaths(), tailwindcss()], 9 | build: { 10 | minify: true, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /e2e/templates/basic/app/root.tsx: -------------------------------------------------------------------------------- 1 | import rootStyles from "./root.css?inline"; 2 | 3 | export function Root({ children }: { children: React.ReactNode }) { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /packages/create-orange/src/replace.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs"; 2 | 3 | export function replace(path: string, replacements: Record) { 4 | if (!fs.existsSync(path)) { 5 | return; 6 | } 7 | 8 | let contents = fs.readFileSync(path, "utf-8"); 9 | 10 | for (const [key, value] of Object.entries(replacements)) { 11 | contents = contents.replaceAll(key, value); 12 | } 13 | 14 | fs.writeFileSync(path, contents); 15 | } 16 | -------------------------------------------------------------------------------- /scripts/version-for-prerelease.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs"; 2 | import { execSync } from "node:child_process"; 3 | 4 | const pkgs = fs.readdirSync("./packages"); 5 | const commitHash = process.env.PR_SHA!.trim().slice(0, 7); 6 | 7 | for (const pkg of pkgs) { 8 | const pkgPath = `./packages/${pkg}`; 9 | const pkgJsonPath = `${pkgPath}/package.json`; 10 | const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); 11 | 12 | pkgJson.version = `0.0.0-${commitHash}`; 13 | 14 | fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); 15 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@biomejs/biome": "2.0.0-beta.1", 4 | "@playwright/test": "^1.53.2", 5 | "@types/node": "^24.0.10", 6 | "dedent": "^1.5.3", 7 | "get-port": "^7.1.0" 8 | }, 9 | "scripts": { 10 | "format": "biome format --write", 11 | "check:lint": "biome lint", 12 | "check:format": "biome format" 13 | }, 14 | "pnpm": { 15 | "onlyBuiltDependencies": [ 16 | "@biomejs/biome", 17 | "@swc/core", 18 | "@tailwindcss/oxide", 19 | "esbuild", 20 | "sharp", 21 | "workerd" 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /e2e/hmr.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "./fixture/index"; 2 | 3 | test.dev("route reload", async ({ page, port, addFile }) => { 4 | await page.goto(`http://localhost:${port}`); 5 | await expect(page.getByText("Hello World")).toBeVisible(); 6 | 7 | await addFile( 8 | "app/routes/bar.tsx", 9 | ` 10 | export default function Bar() { 11 | return
Bar
; 12 | } 13 | ` 14 | ); 15 | 16 | await page.waitForTimeout(2000); 17 | 18 | await page.goto(`http://localhost:${port}/bar`, { timeout: 10000 }); 19 | await expect(page.getByText("Bar")).toBeVisible(); 20 | }); 21 | -------------------------------------------------------------------------------- /packages/core/src/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module "virtual:orange/entrypoints" {} 2 | 3 | declare module "virtual:orange/client-manifest" {} 4 | 5 | declare module "virtual:orange/routes" { 6 | import type * as React from "react"; 7 | 8 | type ReactComponent = (props: { 9 | request: Request; 10 | params: Record; 11 | }) => React.ReactNode | Promise; 12 | 13 | type Module = { 14 | default: ReactComponent | typeof import("./actor.tsx").ReactActor; 15 | }; 16 | 17 | export const routes: { 18 | pattern: URLPattern; 19 | module: Module; 20 | }[]; 21 | } 22 | -------------------------------------------------------------------------------- /e2e/basic.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "./fixture/index"; 2 | 3 | test.multi("hello world", async ({ page, port }) => { 4 | await page.goto(`http://localhost:${port}`); 5 | await expect(page.getByText("Hello World")).toBeVisible(); 6 | }); 7 | 8 | test.multi( 9 | ".browser files arent treated as routes", 10 | async ({ page, port }) => { 11 | await page.goto(`http://localhost:${port}/index.browser`); 12 | await expect(page.getByText("Not found")).toBeVisible(); 13 | }, 14 | { 15 | "app/routes/index.browser.tsx": ` 16 | export default function Index() { 17 | return
Hello World
; 18 | } 19 | `, 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /e2e/templates/basic/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "basic", 4 | "main": "./app/entry.server.tsx", 5 | "compatibility_date": "2025-05-11", 6 | "compatibility_flags": ["nodejs_compat"], 7 | // Where the static asses built by Vite will be served out of. 8 | "assets": { 9 | "directory": "./dist/client" 10 | }, 11 | // Workers Logs 12 | // Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/ 13 | // Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs 14 | "observability": { 15 | "enabled": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.0.0-beta.1/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "includes": ["packages/*/src/**/*.ts", "packages/*/src/**/*.tsx"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 2 16 | }, 17 | "linter": { 18 | "enabled": true, 19 | "rules": { 20 | "recommended": true 21 | } 22 | }, 23 | "javascript": { 24 | "formatter": { 25 | "quoteStyle": "double" 26 | } 27 | }, 28 | "assist": { 29 | "actions": { 30 | "source": { 31 | "organizeImports": "on" 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/create-orange/src/command.ts: -------------------------------------------------------------------------------- 1 | import { createPrompt, usePrefix, useEffect } from "@inquirer/core"; 2 | import { Prompt } from "@inquirer/type"; 3 | import { spawn } from "child_process"; 4 | 5 | export const command: Prompt< 6 | void, 7 | { message: string; bin: string; args: string[]; cwd?: string } 8 | > = createPrompt((config, done: (value: void) => void) => { 9 | const prefix = usePrefix({ status: "loading" }); 10 | 11 | useEffect(() => { 12 | const child = spawn(config.bin, config.args, { cwd: config.cwd }); 13 | 14 | child.on("close", (code) => { 15 | if (code === 0) { 16 | done(undefined); 17 | } else { 18 | done(); 19 | } 20 | }); 21 | }, [config.bin, config.args]); 22 | 23 | return `${prefix} ${config.message}`; 24 | }); 25 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Cloudflare } from "cloudflare"; 3 | import { createCommand } from "@commander-js/extra-typings"; 4 | 5 | import { typesCommand } from "./commands/types.js"; 6 | import { provisionCommand } from "./commands/provision/index.js"; 7 | import { createToken } from "./cf-auth.js"; 8 | 9 | const token = await createToken(); 10 | const client = new Cloudflare({ 11 | apiToken: token, 12 | }); 13 | 14 | const program = createCommand(); 15 | 16 | program 17 | .name("orange") 18 | .description("CLI for Orange.js projects") 19 | .version("0.1.0"); 20 | 21 | // Add the types subcommand 22 | program.addCommand(typesCommand); 23 | 24 | // Add the provision subcommand 25 | program.addCommand(provisionCommand(client)); 26 | 27 | program.parse(); 28 | -------------------------------------------------------------------------------- /packages/vite/src/virtual-module.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Implementation of Vite's virtual module convention 3 | * https://vite.dev/guide/api-plugin#virtual-modules-convention 4 | */ 5 | export class VirtualModule { 6 | #id: string; 7 | 8 | constructor(name: string) { 9 | this.#id = `virtual:orange/${name}`; 10 | } 11 | 12 | is(id: string) { 13 | return id === this.#id || id === this.id; 14 | } 15 | 16 | get id() { 17 | return `\0${this.#id}`; 18 | } 19 | 20 | get raw() { 21 | return this.#id; 22 | } 23 | 24 | get url() { 25 | return `/@id/__x00__${this.#id}`; 26 | } 27 | 28 | static findPrefix(prefix: string, id: string) { 29 | const namespacedPrefix = `\0virtual:orange/${prefix}`; 30 | if (id.startsWith(namespacedPrefix)) { 31 | return id.slice(namespacedPrefix.length - 1); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/create-orange/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-orange", 3 | "version": "0.0.7", 4 | "description": "", 5 | "bin": "./dist/index.js", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "build": "tsc", 10 | "build:watch": "tsc -w" 11 | }, 12 | "exports": { 13 | ".": "./dist/index.js" 14 | }, 15 | "keywords": [], 16 | "author": "zeb@zebulon.dev", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@inquirer/type": "^3.0.3", 20 | "@types/command-exists": "^1.2.3", 21 | "@types/node": "^22.10.7", 22 | "@types/which-pm-runs": "^1.0.2", 23 | "tsx": "^4.19.2", 24 | "typescript": "^5.7.2" 25 | }, 26 | "files": [ 27 | "dist", 28 | "LICENSE" 29 | ], 30 | "dependencies": { 31 | "which-pm-runs": "^1.1.0", 32 | "@inquirer/core": "^10.1.5", 33 | "@inquirer/prompts": "^7.2.4", 34 | "command-exists": "^1.2.9", 35 | "dedent": "^1.5.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/actors/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@orange-js/actors", 3 | "version": "0.3.0", 4 | "description": "", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "import": "./dist/index.js", 11 | "types": "./dist/index.d.ts" 12 | }, 13 | "./client": { 14 | "import": "./dist/client.js", 15 | "types": "./dist/client.d.ts" 16 | } 17 | }, 18 | "scripts": { 19 | "build": "tsc", 20 | "build:watch": "tsc -w" 21 | }, 22 | "devDependencies": { 23 | "@types/react": "^19.1.8", 24 | "@vitejs/plugin-rsc": "^0.4.31", 25 | "react": "^19.1.1", 26 | "typescript": "^5.7.2" 27 | }, 28 | "keywords": [], 29 | "author": "", 30 | "license": "ISC", 31 | "packageManager": "pnpm@10.13.1", 32 | "peerDependencies": { 33 | "@cloudflare/actors": "0.0.1-beta.1", 34 | "@vitejs/plugin-rsc": "^0.4.31", 35 | "react": "^19.1.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /e2e/templates/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "worker-configuration.d.ts", 4 | "env.d.ts", 5 | "**/*.ts", 6 | "**/*.tsx", 7 | "**/.server/**/*.ts", 8 | "**/.server/**/*.tsx", 9 | "**/.client/**/*.ts", 10 | "**/.client/**/*.tsx" 11 | ], 12 | "compilerOptions": { 13 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 14 | "types": [ 15 | "vite/client", 16 | "@cloudflare/workers-types", 17 | "@orange-js/orange/modules", 18 | "./worker-configuration.d.ts", 19 | ], 20 | "isolatedModules": true, 21 | "esModuleInterop": true, 22 | "jsx": "react-jsx", 23 | "moduleResolution": "Bundler", 24 | "resolveJsonModule": true, 25 | "target": "ES2022", 26 | "module": "es2022", 27 | "strict": true, 28 | "allowJs": true, 29 | "skipLibCheck": true, 30 | "forceConsistentCasingInFileNames": true, 31 | "baseUrl": ".", 32 | "paths": { 33 | "~/*": ["./app/*"] 34 | }, 35 | "noEmit": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/cli/src/exec.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | 3 | export async function exec(command: string, args: string[]): Promise { 4 | return new Promise((resolve, reject) => { 5 | const wrangler = spawn(command, args); 6 | const output: { type: "stdout" | "stderr"; data: string }[] = []; 7 | 8 | wrangler.stdout.on("data", (data) => { 9 | output.push({ type: "stdout", data: data.toString() }); 10 | }); 11 | 12 | wrangler.stderr.on("data", (data) => { 13 | output.push({ type: "stderr", data: data.toString() }); 14 | }); 15 | 16 | wrangler.on("close", (code) => { 17 | if (code === 0) { 18 | resolve(); 19 | } else { 20 | for (const o of output) { 21 | if (o.type === "stderr") { 22 | process.stderr.write(o.data); 23 | } else { 24 | process.stdout.write(o.data); 25 | } 26 | } 27 | 28 | reject(new Error(`wrangler types failed with exit code ${code}`)); 29 | } 30 | }); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /packages/core/src/hono.ts: -------------------------------------------------------------------------------- 1 | import { createMiddleware } from "hono/factory"; 2 | import { AsyncLocalStorage } from "node:async_hooks"; 3 | import { HonoBase } from "hono/hono-base"; 4 | import { PropsWithChildren } from "react"; 5 | import * as server from "./server.js"; 6 | 7 | const vars = new AsyncLocalStorage(); 8 | 9 | export function handler( 10 | layout: (props: PropsWithChildren) => React.ReactNode, 11 | options?: server.AppOptions, 12 | ) { 13 | const orangeApp = server.app(layout, options); 14 | 15 | return createMiddleware(async (c) => { 16 | return vars.run(c.var, () => { 17 | return orangeApp.fetch(c.req.raw); 18 | }); 19 | }); 20 | } 21 | 22 | type ExtractEnv = T extends HonoBase 23 | ? Env extends { Variables: infer V } 24 | ? V 25 | : {} 26 | : never; 27 | 28 | export function variables< 29 | App extends HonoBase, 30 | >(): ExtractEnv { 31 | const state = vars.getStore(); 32 | if (!state) { 33 | throw new Error("Not within Hono context"); 34 | } 35 | 36 | return state; 37 | } 38 | -------------------------------------------------------------------------------- /packages/vite/src/util.ts: -------------------------------------------------------------------------------- 1 | export function unreachable(): never { 2 | throw new Error("unreachable"); 3 | } 4 | 5 | export function bail(message: string): never { 6 | throw new Error(message); 7 | } 8 | 9 | export function assert( 10 | whatever: T, 11 | message = "assertion failed", 12 | ): asserts whatever { 13 | if (!whatever) { 14 | throw new Error(message); 15 | } 16 | } 17 | 18 | export function mapObject< 19 | K extends string | number | symbol, 20 | V, 21 | NK extends string | number | symbol = K, 22 | NV = V, 23 | >( 24 | obj: Record, 25 | mapVal: (val: V) => NV, 26 | mapKey: (key: K) => NK = (key) => key as unknown as NK, 27 | ): Record { 28 | const newObj = {} as Record; 29 | for (const key in obj) { 30 | const newKey = mapKey(key); 31 | newObj[newKey] = mapVal(obj[key]); 32 | } 33 | return newObj; 34 | } 35 | 36 | export function isEcmaLike(file: string) { 37 | return ( 38 | file.endsWith(".tsx") || 39 | file.endsWith(".jsx") || 40 | file.endsWith(".ts") || 41 | file.endsWith(".js") 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Zebulon Piasecki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/cli/src/account.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; 2 | import { Cloudflare } from "cloudflare"; 3 | 4 | import { assertNotCancelled, select } from "./prompts.js"; 5 | 6 | export async function promptForAccount(client: Cloudflare): Promise { 7 | if (existsSync("node_modules/.cache/orange/orange-account.json")) { 8 | const { accountId } = JSON.parse( 9 | readFileSync("node_modules/.cache/orange/orange-account.json", "utf-8"), 10 | ); 11 | return accountId; 12 | } 13 | 14 | const accounts = await client.accounts.list({ 15 | per_page: 100, 16 | }); 17 | const account = await select({ 18 | message: "Select an account", 19 | options: accounts.result.map((account) => ({ 20 | title: account.name, 21 | value: account.id, 22 | })), 23 | }); 24 | assertNotCancelled(account); 25 | 26 | mkdirSync("node_modules/.cache/orange", { recursive: true }); 27 | writeFileSync( 28 | "node_modules/.cache/orange/orange-account.json", 29 | JSON.stringify({ accountId: account }, null, 2), 30 | "utf8", 31 | ); 32 | 33 | return account; 34 | } 35 | -------------------------------------------------------------------------------- /e2e/templates/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite build && wrangler dev", 10 | "deploy": "vite build && wrangler deploy", 11 | "cf-typegen": "wrangler types" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "description": "", 17 | "devDependencies": { 18 | "@cloudflare/vite-plugin": "^1.15.2", 19 | "@cloudflare/workers-types": "^4.20250224.0", 20 | "@orange-js/cli": "workspace:*", 21 | "@orange-js/vite": "workspace:*", 22 | "@types/react": "^19.2.7", 23 | "@types/react-dom": "^19.2.3", 24 | "typescript": "^5.8.2", 25 | "vite": "^7.2.4", 26 | "vite-tsconfig-paths": "^5.1.4", 27 | "wrangler": "^4.50.0" 28 | }, 29 | "dependencies": { 30 | "@orange-js/actors": "workspace:", 31 | "@orange-js/orange": "workspace:", 32 | "@tailwindcss/vite": "^4.0.14", 33 | "hono": "^4.6.20", 34 | "react": "^19.2.0", 35 | "react-dom": "^19.2.0", 36 | "tailwindcss": "^4.0.14" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | export default defineConfig({ 4 | testDir: "./e2e", 5 | /* Run tests in files in parallel */ 6 | fullyParallel: true, 7 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 8 | forbidOnly: !!process.env.CI, 9 | /* Retry on CI only */ 10 | retries: process.env.CI ? 2 : 0, 11 | /* Opt out of parallel tests on CI. */ 12 | workers: process.env.CI ? 1 : undefined, 13 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 14 | reporter: "html", 15 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 16 | use: { 17 | /* Base URL to use in actions like `await page.goto('/')`. */ 18 | // baseURL: 'http://localhost:3000', 19 | 20 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 21 | trace: "on-first-retry", 22 | }, 23 | 24 | /* Configure projects for major browsers */ 25 | projects: [ 26 | { 27 | name: "chromium", 28 | use: { ...devices["Desktop Chrome"] }, 29 | }, 30 | ], 31 | }); 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

orange-js

2 | 3 |

4 | A HEAVILY WIP fullstack framework for Cloudflare's developer platform on top of React Router v7 5 |

6 | 7 |

8 | 9 | downloads 10 | 11 | 12 | npm version 13 | 14 | 15 | MIT license 16 | 17 |

18 | 19 | ## Getting started 20 | 21 | ``` 22 | # NPM 23 | $ npm create orange 24 | # Yarn 25 | $ yarn create orange 26 | # PNPM 27 | $ pnpm create orange 28 | # Bun 29 | $ bun create orange 30 | ``` 31 | 32 | ## License 33 | 34 | Distributed under the MIT License. See [LICENSE](LICENSE) for more information. 35 | -------------------------------------------------------------------------------- /packages/vite/src/vite-exec.ts: -------------------------------------------------------------------------------- 1 | import { createServer, version as viteVersion } from "vite"; 2 | import { ViteNodeRunner } from "vite-node/client"; 3 | import { ViteNodeServer } from "vite-node/server"; 4 | import { installSourcemapsSupport } from "vite-node/source-map"; 5 | 6 | export async function execVite(file: string) { 7 | const server = await createServer({ 8 | server: { 9 | preTransformRequests: false, 10 | hmr: false, 11 | watch: null, 12 | }, 13 | ssr: { 14 | external: [], 15 | }, 16 | optimizeDeps: { 17 | noDiscovery: true, 18 | }, 19 | css: { 20 | postcss: {}, 21 | }, 22 | configFile: false, 23 | envFile: false, 24 | plugins: [], 25 | }); 26 | 27 | // @ts-ignore 28 | const node = new ViteNodeServer(server); 29 | 30 | installSourcemapsSupport({ 31 | getSourceMap: (source) => node.getSourceMap(source), 32 | }); 33 | 34 | const runner = new ViteNodeRunner({ 35 | root: server.config.root, 36 | base: server.config.base, 37 | fetchModule(id) { 38 | return node.fetchModule(id); 39 | }, 40 | resolveId(id, importer) { 41 | return node.resolveId(id, importer); 42 | }, 43 | }); 44 | 45 | const result = await runner.executeFile(file); 46 | await server.close(); 47 | 48 | return result; 49 | } 50 | -------------------------------------------------------------------------------- /packages/vite/src/config.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import { Route } from "./routing/index.js"; 3 | import { execVite } from "./vite-exec.js"; 4 | import { fsRoutes } from "./routing/fs-routes.js"; 5 | 6 | export type Config = { 7 | /** 8 | * The routes in the application. 9 | */ 10 | routes?: Route[]; 11 | }; 12 | 13 | export type ResolvedConfig = Required; 14 | 15 | let _configPromise: Promise | undefined; 16 | 17 | let defaultConfig: ResolvedConfig = { 18 | routes: fsRoutes(), 19 | }; 20 | 21 | async function resolveConfigImpl(): Promise { 22 | for (const file of ["orange.config.ts", "orange.config.js"]) { 23 | if (fs.existsSync(file)) { 24 | const mod = await execVite(file); 25 | const config = mod.default; 26 | if (!config) { 27 | console.error("orange.config.ts did not export a default config"); 28 | process.exit(1); 29 | } 30 | return { ...defaultConfig, ...config }; 31 | } 32 | } 33 | 34 | return defaultConfig; 35 | } 36 | 37 | export async function resolveConfig(): Promise { 38 | if (!_configPromise) { 39 | _configPromise = resolveConfigImpl(); 40 | } 41 | return _configPromise; 42 | } 43 | 44 | export function resetConfig() { 45 | defaultConfig = { routes: fsRoutes() }; 46 | _configPromise = undefined; 47 | } 48 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@orange-js/cli", 3 | "version": "0.3.0", 4 | "description": "CLI for Orange.js projects", 5 | "bin": { 6 | "orange": "./dist/index.js" 7 | }, 8 | "main": "dist/index.js", 9 | "type": "module", 10 | "types": "dist/index.d.ts", 11 | "scripts": { 12 | "build": "tsc", 13 | "build:watch": "tsc -w" 14 | }, 15 | "exports": { 16 | ".": "./dist/index.js" 17 | }, 18 | "keywords": [], 19 | "author": "zeb@zebulon.dev", 20 | "license": "MIT", 21 | "devDependencies": { 22 | "@types/node": "^22.13.0", 23 | "@types/prompts": "^2.4.9", 24 | "dedent": "^1.5.3", 25 | "typescript": "^5.7.2", 26 | "vite": "6.2", 27 | "wrangler": "^4.50.0" 28 | }, 29 | "files": [ 30 | "dist", 31 | "LICENSE" 32 | ], 33 | "dependencies": { 34 | "@clack/core": "^0.4.1", 35 | "@clack/prompts": "^0.10.0", 36 | "@commander-js/extra-typings": "^13.1.0", 37 | "chalk": "^5.4.1", 38 | "cloudflare": "^4.2.0", 39 | "commander": "^11.1.0", 40 | "detect-package-manager": "^3.0.2", 41 | "is-unicode-supported": "^2.1.0", 42 | "open": "^10.1.0", 43 | "sisteransi": "^1.0.5", 44 | "toml": "^3.0.0", 45 | "ts-pattern": "^5.7.0", 46 | "tsx": "^4.19.2", 47 | "xdg-app-paths": "^8.3.0" 48 | }, 49 | "peerDependencies": { 50 | "@orange-js/vite": "workspace:*", 51 | "vite": "6.2", 52 | "wrangler": "^4.7.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /e2e/error-handling.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "./fixture/index"; 2 | 3 | test.multi( 4 | "immediate error handling with default error handler", 5 | async ({ page, port, isDev }) => { 6 | await page.goto(`http://localhost:${port}`); 7 | await expect(page.getByText("Something went wrong")).toBeVisible(); 8 | if (isDev) { 9 | await expect(page.getByText("Hello World")).toBeVisible(); 10 | } 11 | }, 12 | { 13 | "app/routes/index.tsx": ` 14 | export default function Index() { 15 | throw new Error("Hello World"); 16 | } 17 | `, 18 | } 19 | ); 20 | 21 | test.multi( 22 | "delayed error handling with default error handler", 23 | async ({ page, port, isDev }) => { 24 | await page.goto(`http://localhost:${port}`); 25 | await expect(page.getByText("Something went wrong")).toBeVisible(); 26 | if (isDev) { 27 | await expect(page.getByText("Hello World")).toBeVisible(); 28 | } 29 | }, 30 | { 31 | "app/routes/index.tsx": ` 32 | import { Suspense } from "react"; 33 | 34 | async function Err() { 35 | await new Promise((resolve) => setTimeout(resolve, 1000)); 36 | throw new Error("Hello World"); 37 | } 38 | 39 | export default function Index() { 40 | return ( 41 | Loading...}> 42 | 43 | 44 | ); 45 | } 46 | `, 47 | } 48 | ); 49 | -------------------------------------------------------------------------------- /packages/actors/src/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect, useState } from "react"; 3 | 4 | type ClientComponentProps = { 5 | children: React.ReactNode; 6 | actorName: string; 7 | id: string; 8 | }; 9 | 10 | export function ClientComponent({ 11 | children, 12 | actorName, 13 | id, 14 | }: ClientComponentProps) { 15 | const [component, setComponent] = useState(null); 16 | 17 | if (!import.meta.env.SSR) { 18 | useEffect(() => { 19 | const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; 20 | const ws = new WebSocket( 21 | `${protocol}//${window.location.host}/${actorName}/${id}`, 22 | ); 23 | 24 | ws.addEventListener("message", async (event) => { 25 | const data = event.data as Blob; 26 | const bytes = await data.arrayBuffer(); 27 | const stream = new ReadableStream({ 28 | start(controller) { 29 | controller.enqueue(new Uint8Array(bytes)); 30 | controller.close(); 31 | }, 32 | }); 33 | const { createFromReadableStream } = await import( 34 | "@vitejs/plugin-rsc/browser" 35 | ); 36 | 37 | const created = await createFromReadableStream(stream); 38 | setComponent((created as any).root); 39 | }); 40 | 41 | return () => ws.close(); 42 | }, []); 43 | } 44 | 45 | if (!component) { 46 | return children; 47 | } 48 | 49 | return component; 50 | } 51 | -------------------------------------------------------------------------------- /packages/cli/src/wrangler.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from "node:fs"; 2 | import path from "node:path"; 3 | import { log } from "./prompts.js"; 4 | import xdgAppPaths from "xdg-app-paths"; 5 | import { parse } from "toml"; 6 | import { promptForAccount } from "./account.js"; 7 | import Cloudflare from "cloudflare"; 8 | 9 | export function getAuthToken(): string { 10 | // @ts-ignore 11 | const wranglerPath: string = xdgAppPaths(".wrangler").config(); 12 | if (!existsSync(wranglerPath)) { 13 | log( 14 | "Wrangler account not found", 15 | "Please run `wrangler login` to login to Wrangler", 16 | ); 17 | process.exit(1); 18 | } 19 | 20 | const configPath = path.join(wranglerPath, `config/default.toml`); 21 | if (!existsSync(configPath)) { 22 | log( 23 | "Wrangler config not found", 24 | "Please run `wrangler login` to login to Wrangler", 25 | ); 26 | process.exit(1); 27 | } 28 | 29 | const contents = readFileSync(configPath, "utf-8"); 30 | const config = parse(contents); 31 | return config.oauth_token; 32 | } 33 | 34 | export async function readAccountId(client: Cloudflare) { 35 | if (!existsSync("node_modules/.cache/wrangler/wrangler-account.json")) { 36 | return await promptForAccount(client); 37 | } 38 | 39 | const config = readFileSync( 40 | "node_modules/.cache/wrangler/wrangler-account.json", 41 | "utf-8", 42 | ); 43 | const json = JSON.parse(config); 44 | return json.account.id; 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Creates 0.0.0-$COMMIT versions for every package and publishes them to NPM 3 | # 4 | name: Publish preleases to NPM 5 | on: 6 | release: 7 | types: [published] 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: pnpm/action-setup@v3 14 | with: 15 | version: 10 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: "23.x" 19 | registry-url: "https://registry.npmjs.org" 20 | - run: pnpm install --frozen-lockfile 21 | - run: pnpm recursive run --sort --workspace-concurrency=1 build 22 | 23 | - name: Publish core 24 | run: pnpm publish 25 | working-directory: packages/core 26 | env: 27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | 29 | - name: Publish actors 30 | run: pnpm publish 31 | working-directory: packages/actors 32 | env: 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | 35 | - name: Publish vite 36 | run: pnpm publish 37 | working-directory: packages/vite 38 | env: 39 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | 41 | - name: Publish create-orange 42 | run: pnpm publish 43 | working-directory: packages/create-orange 44 | env: 45 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | 47 | - name: Publish cli 48 | run: pnpm publish 49 | working-directory: packages/cli 50 | env: 51 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 52 | -------------------------------------------------------------------------------- /packages/vite/src/plugins/route-reload.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, ViteDevServer } from "vite"; 2 | import * as path from "node:path"; 3 | import { resetRoutes } from "./routes.js"; 4 | import { resetConfig, resolveConfig, ResolvedConfig } from "../config.js"; 5 | 6 | // Prevent double reload for file renames 7 | let reloadCount = 0; 8 | 9 | async function forceReload( 10 | server: ViteDevServer, 11 | reloadId: number, 12 | updateConfig: (newConfig: ResolvedConfig) => void, 13 | ) { 14 | if (reloadId !== reloadCount) { 15 | return; 16 | } 17 | 18 | resetRoutes(); 19 | resetConfig(); 20 | updateConfig(await resolveConfig()); 21 | 22 | // TODO: This is a hack to force a full reload 23 | await server.restart(); 24 | server.ws.send({ type: "full-reload" }); 25 | } 26 | 27 | const pathsToWatch = ["orange.config.ts", "orange.config.js", "app/routes"].map( 28 | (file) => path.resolve(file), 29 | ); 30 | 31 | export function routeReload( 32 | updateConfig: (newConfig: ResolvedConfig) => void, 33 | ): Plugin { 34 | return { 35 | name: "orange:reload-routes", 36 | async configureServer(server) { 37 | const onFileChange = async (filePath: string) => { 38 | if ( 39 | pathsToWatch.includes(filePath) || 40 | pathsToWatch.some((p) => filePath.startsWith(p)) 41 | ) { 42 | const reloadId = ++reloadCount; 43 | setTimeout(() => forceReload(server, reloadId, updateConfig), 100); 44 | } 45 | }; 46 | 47 | server.watcher.on("add", onFileChange); 48 | server.watcher.on("unlink", onFileChange); 49 | }, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /packages/vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@orange-js/vite", 3 | "type": "module", 4 | "version": "0.3.0", 5 | "description": "", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "build": "tsc", 10 | "build:watch": "tsc -w" 11 | }, 12 | "exports": { 13 | ".": { 14 | "import": "./dist/index.js", 15 | "types": "./dist/index.d.ts" 16 | }, 17 | "./routes": { 18 | "import": "./dist/routes.js", 19 | "types": "./dist/routes.d.ts" 20 | }, 21 | "./config": { 22 | "import": "./dist/config.js", 23 | "types": "./dist/config.d.ts" 24 | } 25 | }, 26 | "keywords": [], 27 | "author": "zeb@zebulon.dev", 28 | "license": "MIT", 29 | "devDependencies": { 30 | "@types/babel__generator": "^7.6.8", 31 | "@types/babel__traverse": "^7.20.6", 32 | "@types/node": "^22.13.0", 33 | "typescript": "^5.7.2", 34 | "vite": "^7.2.4" 35 | }, 36 | "dependencies": { 37 | "@babel/generator": "^7.26.5", 38 | "@babel/parser": "^7.26.7", 39 | "@babel/plugin-proposal-decorators": "^7.28.0", 40 | "@babel/traverse": "^7.26.7", 41 | "@babel/types": "^7.26.7", 42 | "@cloudflare/vite-plugin": "^1.15.3", 43 | "@swc-node/core": "^1.13.3", 44 | "@swc/core": "^1.11.20", 45 | "@vitejs/plugin-react": "^5.1.1", 46 | "@vitejs/plugin-rsc": "^0.5.2", 47 | "dedent": "^1.5.3", 48 | "es-module-lexer": "^1.6.0", 49 | "minimatch": "^10.0.1", 50 | "react-refresh": "^0.16.0", 51 | "vite-node": "^3.2.4", 52 | "vitest": "^3.2.4" 53 | }, 54 | "files": [ 55 | "dist", 56 | "LICENSE" 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /packages/actors/src/observed.tsx: -------------------------------------------------------------------------------- 1 | import { Actor } from "./index.js"; 2 | import { type JSX } from "react"; 3 | import { renderToReadableStream } from "@vitejs/plugin-rsc/rsc"; 4 | 5 | type ClassMethodDecorator = ( 6 | value: (...args: Args) => Return, 7 | context: ClassMethodDecoratorContext, 8 | ) => any; 9 | 10 | export const observedSymbol = Symbol("orange:observed"); 11 | 12 | export function Observed( 13 | ...names: string[] 14 | ): ClassMethodDecorator> { 15 | return function (this: Actor, value, context) { 16 | context.addInitializer(function () { 17 | const self = this as Actor; 18 | 19 | // @ts-ignore 20 | self[observedSymbol] = true; 21 | 22 | self["onPersist"] = async () => { 23 | const ret = await value.apply(this); 24 | const stream = renderToReadableStream({ 25 | root: ret, 26 | }); 27 | 28 | const rscPayload = await new Response(stream).bytes(); 29 | 30 | // @ts-ignore 31 | const websockets = self.ctx.getWebSockets(); 32 | 33 | // @ts-ignore 34 | for (const ws of websockets) { 35 | ws.send(rscPayload); 36 | } 37 | }; 38 | 39 | self["fetch"] = async (request: Request) => { 40 | const webSocketPair = new WebSocketPair(); 41 | const [client, server] = Object.values(webSocketPair); 42 | 43 | // @ts-ignore 44 | self.ctx.acceptWebSocket(server); 45 | 46 | return new Response(null, { 47 | status: 101, 48 | webSocket: client, 49 | }); 50 | }; 51 | }); 52 | 53 | return value; 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /packages/vite/src/routing/fs-routes.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync } from "node:fs"; 2 | import * as path from "node:path"; 3 | import { isEcmaLike } from "../util.js"; 4 | import type { Route } from "./index.js"; 5 | 6 | export function fsRoutes(): Route[] { 7 | const routesDir = path.resolve(process.cwd(), "app", "routes"); 8 | const routes = walkDir(routesDir).map((route) => 9 | route.replace(`${routesDir}/`, ""), 10 | ); 11 | 12 | return routes 13 | .filter((route) => isEcmaLike(route)) 14 | .filter((route) => !isBrowserFile(route)) 15 | .map((route) => { 16 | const pattern = fileNameToPattern(route); 17 | 18 | return { 19 | pattern, 20 | file: path.resolve(routesDir, route), 21 | }; 22 | }); 23 | } 24 | 25 | function fileNameToPattern(fileName: string) { 26 | let pattern = 27 | "/" + fileName.replace(/\.(t|j)sx?$/, "").replace(/\/index$/, ""); 28 | 29 | if (pattern === "/index") { 30 | pattern = "/"; 31 | } 32 | 33 | if (pattern.endsWith("/index")) { 34 | pattern = pattern.slice(0, -6); 35 | } 36 | 37 | return pattern.replace("$", ":"); 38 | } 39 | 40 | function walkDir(dir: string) { 41 | let files: string[] = []; 42 | 43 | const dirents = readdirSync(dir, { withFileTypes: true }); 44 | 45 | for (const dirent of dirents) { 46 | const fullPath = path.join(dir, dirent.name); 47 | if (dirent.isSymbolicLink()) continue; 48 | 49 | if (dirent.isDirectory()) { 50 | files = files.concat(walkDir(fullPath)); 51 | } else if (dirent.isFile()) { 52 | files.push(fullPath); 53 | } 54 | } 55 | 56 | return files; 57 | } 58 | 59 | function isBrowserFile(fileName: string) { 60 | return /\.browser\.(tsx?|jsx?)$/.test(fileName); 61 | } 62 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@orange-js/orange", 3 | "version": "0.3.0", 4 | "description": "", 5 | "keywords": [], 6 | "author": "zeb@zebulon.dev", 7 | "license": "MIT", 8 | "type": "module", 9 | "main": "dist/index.js", 10 | "types": "dist/index.d.ts", 11 | "scripts": { 12 | "build": "tsc", 13 | "build:watch": "tsc -w" 14 | }, 15 | "exports": { 16 | ".": { 17 | "import": "./dist/index.js", 18 | "types": "./dist/index.d.ts" 19 | }, 20 | "./client": { 21 | "import": "./dist/client.js", 22 | "types": "./dist/client.d.ts" 23 | }, 24 | "./ssr": { 25 | "import": "./dist/ssr.js", 26 | "types": "./dist/ssr.d.ts" 27 | }, 28 | "./server": { 29 | "import": "./dist/server.js", 30 | "types": "./dist/server.d.ts" 31 | }, 32 | "./modules": { 33 | "types": "./src/modules.d.ts" 34 | }, 35 | "./hono": { 36 | "import": "./dist/hono.js", 37 | "types": "./dist/hono.d.ts" 38 | } 39 | }, 40 | "optionalDependencies": { 41 | "@cloudflare/actors": "0.0.1-beta.1" 42 | }, 43 | "peerDependencies": { 44 | "react": "^19.2.0", 45 | "react-dom": "^19.2.0" 46 | }, 47 | "devDependencies": { 48 | "@cloudflare/actors": "0.0.1-beta.1", 49 | "@cloudflare/workers-types": "^4.20250413.0", 50 | "@types/node": "^22.13.0", 51 | "@types/react": "^19.2.7", 52 | "@types/react-dom": "^19.2.3", 53 | "@vitejs/plugin-rsc": "^0.5.2", 54 | "react": "^19.2.0", 55 | "react-dom": "^19.2.0", 56 | "typescript": "^5.7.2" 57 | }, 58 | "files": [ 59 | "dist", 60 | "src/modules.d.ts", 61 | "LICENSE" 62 | ], 63 | "dependencies": { 64 | "hono": "^4.6.20", 65 | "rsc-html-stream": "^0.0.7" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/vite/src/plugins/config.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from "vite"; 2 | 3 | export function configPlugin(): Plugin { 4 | return { 5 | name: "orange:config", 6 | config: (config) => { 7 | return { 8 | ...config, 9 | environments: { 10 | rsc: { 11 | build: { 12 | rollupOptions: { 13 | // ensure `default` export only in cloudflare entry output 14 | preserveEntrySignatures: "exports-only", 15 | }, 16 | }, 17 | optimizeDeps: { 18 | include: ["react", "react-dom", "@orange-js/actors"], 19 | exclude: ["virtual:orange/routes", "@orange-js/actors/client"], 20 | }, 21 | }, 22 | ssr: { 23 | keepProcessEnv: false, 24 | build: { 25 | // build `ssr` inside `rsc` directory so that 26 | // wrangler can deploy self-contained `dist/rsc` 27 | outDir: "./dist/rsc/ssr", 28 | }, 29 | resolve: { 30 | noExternal: true, 31 | dedupe: ["react", "react-dom"], 32 | }, 33 | optimizeDeps: { 34 | include: ["react", "react-dom"], 35 | }, 36 | }, 37 | client: { 38 | build: { 39 | rollupOptions: { 40 | external: ["virtual:orange/routes"], 41 | }, 42 | }, 43 | resolve: { 44 | dedupe: ["react", "react-dom"], 45 | }, 46 | optimizeDeps: { 47 | exclude: [ 48 | "cloudflare:workers", 49 | "virtual:orange/routes", 50 | "cloudflare:workerflows", 51 | ], 52 | }, 53 | }, 54 | }, 55 | }; 56 | }, 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /packages/vite/src/plugins/routes.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from "vite"; 2 | import { VirtualModule } from "../virtual-module.js"; 3 | import * as path from "node:path"; 4 | import { fsRoutes } from "../routing/fs-routes.js"; 5 | import { Route } from "../routing/index.js"; 6 | import { Config } from "../config.js"; 7 | 8 | const vmod = new VirtualModule("routes"); 9 | 10 | let _routes: Route[] | undefined; 11 | 12 | export function resetRoutes() { 13 | _routes = undefined; 14 | } 15 | 16 | export function routesPlugin(config: () => Config): Plugin { 17 | return { 18 | name: "orange:routes", 19 | enforce: "pre", 20 | applyToEnvironment(environment) { 21 | return environment.name === "rsc"; 22 | }, 23 | resolveId(source) { 24 | if (source === "virtual:orange/routes") { 25 | return vmod.id; 26 | } 27 | }, 28 | async load(id) { 29 | if (id === vmod.id) { 30 | const routes = _routes ?? config().routes ?? fsRoutes(); 31 | _routes = routes; 32 | 33 | const ids = Object.fromEntries( 34 | routes.map((route) => [ 35 | route.pattern, 36 | `route_${Math.random().toString(36).substring(2, 15)}`, 37 | ]), 38 | ); 39 | const imports = routes.map((route) => { 40 | return `import * as ${ids[route.pattern]} from "${path.resolve( 41 | route.file, 42 | )}";`; 43 | }); 44 | 45 | const routeDeclarations = routes.map( 46 | (route) => 47 | `{ pattern: new URLPattern({ pathname: "${ 48 | route.pattern 49 | }" }), module: ${ids[route.pattern]} }`, 50 | ); 51 | 52 | return { 53 | code: `${imports.join("\n")} 54 | export const routes = [${routeDeclarations.join(",\n")}]; 55 | `, 56 | }; 57 | } 58 | }, 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /packages/vite/src/plugins/isolation.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Allows for creating modules that are isolated from client or server bundles by using 3 | a `.client` or `.server` suffix. 4 | */ 5 | import * as path from "node:path"; 6 | import { Plugin } from "vite"; 7 | import { init, parse } from "es-module-lexer"; 8 | 9 | export function isolation(): Plugin[] { 10 | const clientRegex = /.+.client(?:.(?:j|t)sx?)?$/g; 11 | const serverRegex = /.+.server(?:.(?:j|t)sx?)?$/g; 12 | 13 | return [ 14 | { 15 | // Prevent client-only modules from being imported in the server bundle 16 | name: "orange:client-isolation", 17 | applyToEnvironment(environment) { 18 | return environment.name !== "client"; 19 | }, 20 | async transform(code, id) { 21 | if (clientRegex.test(id) && inAppDir(id)) { 22 | this.debug(`Client-only module ${id} is being isolated`); 23 | 24 | await init; 25 | 26 | const [_, exports] = parse(code); 27 | 28 | return emptyExports(exports.map((it) => it.n)); 29 | } 30 | }, 31 | }, 32 | { 33 | // Prevent server-only modules from being imported in the client bundle 34 | name: "orange:server-isolation", 35 | applyToEnvironment(environment) { 36 | return environment.name === "client"; 37 | }, 38 | async transform(code, id) { 39 | if (serverRegex.test(id) && inAppDir(id)) { 40 | this.debug(`Server-only module ${id} is being isolated`); 41 | 42 | await init; 43 | 44 | const [_, exports] = parse(code); 45 | 46 | return emptyExports(exports.map((it) => it.n)); 47 | } 48 | }, 49 | }, 50 | ]; 51 | } 52 | 53 | const inAppDir = (importPath: string) => 54 | path.resolve(importPath).startsWith(path.resolve("./app")); 55 | 56 | const emptyExports = (exports: string[]) => { 57 | return exports 58 | .map((e) => 59 | e === "default" 60 | ? "export default undefined;" 61 | : `export const ${e} = undefined;`, 62 | ) 63 | .join("\n"); 64 | }; 65 | -------------------------------------------------------------------------------- /packages/core/src/error-handling/browser.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | export class ErrorBoundary extends React.Component<{ 6 | children: React.ReactNode; 7 | fallback: (props: { error: Error | null }) => React.ReactNode; 8 | }> { 9 | state = { hasError: false, error: null }; 10 | 11 | static getDerivedStateFromError(error: Error) { 12 | return { 13 | hasError: true, 14 | error: process.env.NODE_ENV === "development" ? error : null, 15 | }; 16 | } 17 | 18 | render() { 19 | if (this.state.hasError) { 20 | const Fallback = this.props.fallback; 21 | return ; 22 | } 23 | 24 | return this.props.children; 25 | } 26 | } 27 | 28 | export function ErrorFallback({ error }: { error: Error | null }) { 29 | let content; 30 | if (process.env.NODE_ENV === "development") { 31 | if (error?.stack) { 32 | content = error.stack.toString(); 33 | } else if (error) { 34 | content = error.toString(); 35 | } else { 36 | content = ""; 37 | } 38 | } 39 | 40 | return ( 41 |
52 | 58 |

Something went wrong

59 | {content && ( 60 |
73 |           {content}
74 |         
75 | )} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /packages/vite/src/plugins/preserve-class-names.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import type { Plugin } from "vite"; 3 | import { isEcmaLike } from "../util.js"; 4 | import _parse from "@babel/parser"; 5 | import _generate from "@babel/generator"; 6 | import _traverse from "@babel/traverse"; 7 | import { 8 | callExpression, 9 | expressionStatement, 10 | identifier, 11 | memberExpression, 12 | objectExpression, 13 | objectProperty, 14 | staticBlock, 15 | stringLiteral, 16 | thisExpression, 17 | booleanLiteral, 18 | } from "@babel/types"; 19 | 20 | const parse = _parse.parse; 21 | const generate = _generate.default; 22 | const traverse = _traverse.default; 23 | 24 | export function preserveClassNames(): Plugin { 25 | return { 26 | name: "orange:preserve-class-names", 27 | enforce: "pre", 28 | transform(code, id) { 29 | if (!isEcmaLike(id) || !inAppDir(id)) { 30 | return; 31 | } 32 | 33 | const ast = parse(code, { 34 | sourceType: "module", 35 | plugins: ["jsx", "typescript", "decorators"], 36 | }); 37 | 38 | traverse(ast, { 39 | ClassDeclaration(path) { 40 | const { id } = path.node; 41 | if (!id) return; 42 | 43 | path.node.body.body.unshift( 44 | staticBlock([ 45 | expressionStatement( 46 | callExpression( 47 | memberExpression( 48 | identifier("Object"), 49 | identifier("defineProperty"), 50 | ), 51 | [ 52 | thisExpression(), 53 | stringLiteral("name"), 54 | objectExpression([ 55 | objectProperty( 56 | stringLiteral("value"), 57 | stringLiteral(id.name), 58 | ), 59 | objectProperty( 60 | stringLiteral("enumerable"), 61 | booleanLiteral(false), 62 | ), 63 | ]), 64 | ], 65 | ), 66 | ), 67 | ]), 68 | ); 69 | }, 70 | }); 71 | 72 | return generate(ast).code; 73 | }, 74 | }; 75 | } 76 | 77 | const inAppDir = (importPath: string) => 78 | path.resolve(importPath).startsWith(path.resolve("./app")); 79 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yaml: -------------------------------------------------------------------------------- 1 | name: E2E Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | permissions: 7 | contents: read 8 | pull-requests: write 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: pnpm/action-setup@v3 15 | with: 16 | version: 10 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: "23.x" 20 | registry-url: "https://registry.npmjs.org" 21 | - run: pnpm install --frozen-lockfile 22 | - run: pnpm recursive run --sort --workspace-concurrency=1 build 23 | 24 | - name: Install Playwright 25 | run: pnpm exec playwright install 26 | 27 | - name: Run E2E Tests 28 | run: pnpm playwright test --reporter=html --trace on 29 | 30 | - uses: ryand56/r2-upload-action@latest # Can be any release 31 | with: 32 | r2-account-id: ${{ secrets.R2_ACCOUNT_ID }} 33 | r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} 34 | r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} 35 | r2-bucket: ${{ secrets.R2_BUCKET }} 36 | source-dir: playwright-report 37 | destination-dir: "run-${{ github.run_id }}-${{ github.run_attempt }}" 38 | output-file-url: true # defaults to true 39 | multipart-size: 100 # If the file size is greater than the value provided here, then use multipart upload 40 | max-retries: 5 # The maximum number of retries it takes to upload a multipart chunk until it moves on to the next part 41 | multipart-concurrent: true # Whether to concurrently upload a multipart chunk 42 | keep-file-fresh: false # defaults to false 43 | 44 | - name: Leave PR comment with test results 45 | if: github.event_name == 'pull_request' 46 | uses: actions/github-script@v6 47 | with: 48 | github-token: ${{ secrets.GITHUB_TOKEN }} 49 | script: | 50 | const comment = `## E2E Test Results 51 | [View the test results](https://playwright.orange-js.dev/run-${{ github.run_id }}-${{ github.run_attempt }}/index.html)`; 52 | 53 | github.rest.issues.createComment({ 54 | issue_number: context.issue.number, 55 | owner: context.repo.owner, 56 | repo: context.repo.repo, 57 | body: comment 58 | }); 59 | -------------------------------------------------------------------------------- /packages/cli/src/cf-auth.ts: -------------------------------------------------------------------------------- 1 | import open from "open"; 2 | import xdgAppPaths from "xdg-app-paths"; 3 | import { mkdirSync, existsSync, writeFileSync, readFileSync } from "node:fs"; 4 | import * as path from "node:path"; 5 | 6 | import { assertNotCancelled, password, step } from "./prompts.js"; 7 | 8 | // @ts-ignore 9 | const orangePath: string = xdgAppPaths(".orange-cli").config(); 10 | 11 | if (!existsSync(orangePath)) { 12 | mkdirSync(orangePath, { recursive: true }); 13 | } 14 | 15 | const permissionGroupKeys = [ 16 | { key: "account", type: "read" }, 17 | { key: "user", type: "read" }, 18 | { key: "workers", type: "edit" }, 19 | { key: "workers_kv", type: "edit" }, 20 | { key: "workers_routes", type: "edit" }, 21 | { key: "workers_scripts", type: "edit" }, 22 | { key: "workers_tail", type: "read" }, 23 | { key: "d1", type: "edit" }, 24 | { key: "pages", type: "edit" }, 25 | { key: "zone", type: "read" }, 26 | { key: "ssl_certs", type: "edit" }, 27 | { key: "ai", type: "edit" }, 28 | { key: "queues", type: "edit" }, 29 | { key: "pipelines", type: "edit" }, 30 | { key: "workers_r2", type: "edit" }, 31 | { key: "workers_kv_storage", type: "edit" }, 32 | { key: "query_cache", type: "edit" }, 33 | { key: "queues", type: "edit" }, 34 | { key: "workers_ci", type: "edit" }, 35 | { key: "ai", type: "edit" }, 36 | ]; 37 | 38 | function createAPITokenURL() { 39 | const url = new URL("http://dash.cloudflare.com/profile/api-tokens"); 40 | url.searchParams.set("accountId", "*"); 41 | url.searchParams.set("zoneId", "all"); 42 | url.searchParams.set( 43 | "permissionGroupKeys", 44 | JSON.stringify(permissionGroupKeys), 45 | ); 46 | url.searchParams.set("name", "Orange CLI"); 47 | return url.toString(); 48 | } 49 | 50 | export async function createToken() { 51 | const existingToken = loadToken(); 52 | if (existingToken) { 53 | return existingToken; 54 | } 55 | 56 | const url = createAPITokenURL(); 57 | step( 58 | "Opening the browser to create an API token, create the token and then paste it below...", 59 | ); 60 | 61 | await open(url); 62 | 63 | const token = await password({ 64 | message: "Enter the token", 65 | placeholder: "Press Contiue to Summary -> Create Token", 66 | }); 67 | 68 | assertNotCancelled(token); 69 | saveToken(token); 70 | 71 | return token; 72 | } 73 | 74 | function saveToken(token: string) { 75 | writeFileSync( 76 | path.join(orangePath, "orange.json"), 77 | JSON.stringify({ token }), 78 | ); 79 | } 80 | 81 | function loadToken(): string | undefined { 82 | try { 83 | const token = readFileSync(path.join(orangePath, "orange.json"), "utf-8"); 84 | return JSON.parse(token).token; 85 | } catch (error) { 86 | return undefined; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /packages/create-orange/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import commandExists from "command-exists"; 3 | import whichPm from "which-pm-runs"; 4 | import { input, confirm } from "@inquirer/prompts"; 5 | import { rmSync } from "node:fs"; 6 | import { replace } from "./replace.js"; 7 | import { command } from "./command.js"; 8 | import { execSync } from "node:child_process"; 9 | 10 | async function main() { 11 | const gitExists = await commandExists("git"); 12 | if (!gitExists) { 13 | console.error("git is not installed"); 14 | process.exit(1); 15 | } 16 | 17 | const name = await input({ 18 | message: "What do you want to name your project?", 19 | default: process.argv[2], 20 | validate: (value) => value.length > 0, 21 | }); 22 | 23 | const branch = process.argv[3] ?? "main"; 24 | 25 | await command( 26 | { 27 | message: "Cloning template...", 28 | bin: "git", 29 | args: [ 30 | "clone", 31 | "https://github.com/zebp/orange-template.git", 32 | name, 33 | "--depth", 34 | "1", 35 | "--branch", 36 | branch, 37 | ], 38 | }, 39 | { clearPromptOnDone: true }, 40 | ); 41 | 42 | const replacements = { 43 | "orange-template": name, 44 | }; 45 | 46 | rmSync(`${name}/.git`, { recursive: true, force: true }); 47 | replace(`${name}/package.json`, replacements); 48 | replace(`${name}/wrangler.jsonc`, replacements); 49 | replace(`${name}/README.md`, replacements); 50 | 51 | const doInstallDeps = await confirm({ 52 | message: "Do you want to install dependencies?", 53 | }); 54 | 55 | const pm = whichPm() ?? { name: "npm" }; 56 | if (doInstallDeps) { 57 | await command( 58 | { 59 | message: "Installing dependencies...", 60 | bin: pm.name, 61 | args: ["install"], 62 | cwd: name, 63 | }, 64 | { clearPromptOnDone: true }, 65 | ); 66 | } 67 | 68 | if ( 69 | await confirm({ message: "Do you want to initialize a git repository?" }) 70 | ) { 71 | await command( 72 | { 73 | message: "Initializing git repository...", 74 | bin: "git", 75 | args: ["init"], 76 | cwd: name, 77 | }, 78 | { clearPromptOnDone: true }, 79 | ); 80 | 81 | execSync("git add .", { cwd: name }); 82 | 83 | await command( 84 | { 85 | message: "Creating initial commit...", 86 | bin: "git", 87 | args: ["commit", "-m", "Initial commit"], 88 | cwd: name, 89 | }, 90 | { clearPromptOnDone: true }, 91 | ); 92 | } 93 | 94 | console.log(` 95 | \n\n 96 | 🍊 Change directories: cd ${name} 97 | Start dev server: ${pm.name} run dev 98 | Deploy: ${pm.name} run deploy 99 | \n\n 100 | `); 101 | } 102 | 103 | main(); 104 | -------------------------------------------------------------------------------- /packages/vite/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from "vite"; 2 | 3 | import { cloudflare } from "@cloudflare/vite-plugin"; 4 | import rsc from "@vitejs/plugin-rsc"; 5 | import react from "@vitejs/plugin-react"; 6 | import * as fs from "node:fs"; 7 | import * as path from "node:path"; 8 | 9 | import { configPlugin } from "./plugins/config.js"; 10 | import { routesPlugin } from "./plugins/routes.js"; 11 | import { isolation } from "./plugins/isolation.js"; 12 | import { Config, resolveConfig } from "./config.js"; 13 | import { preserveClassNames } from "./plugins/preserve-class-names.js"; 14 | import { routeReload } from "./plugins/route-reload.js"; 15 | 16 | export * from "./routing/fs-routes.js"; 17 | 18 | export type OrangeRSCPluginOptions = { 19 | cloudflare?: Parameters[0]; 20 | }; 21 | 22 | export default function orange( 23 | options: OrangeRSCPluginOptions = {}, 24 | ): PluginOption[] { 25 | let _config: Config; 26 | 27 | const config = () => _config; 28 | 29 | return [ 30 | { 31 | name: "orange:settings", 32 | // @ts-ignore - this is a magic property used for the orange CLI 33 | orangeOptions: { 34 | cloudflare: options.cloudflare, 35 | }, 36 | async config() { 37 | _config = await resolveConfig(); 38 | }, 39 | }, 40 | isolation(), 41 | configPlugin(), 42 | react({ 43 | babel: { 44 | plugins: [ 45 | ["@babel/plugin-proposal-decorators", { version: "2023-11" }], 46 | ], 47 | }, 48 | }), 49 | rsc({ 50 | entries: { 51 | client: entrypoint( 52 | "entry.browser", 53 | "node_modules/@orange-js/vite/dist/entrypoints/entry.browser.js", 54 | ), 55 | ssr: entrypoint( 56 | "entry.ssr", 57 | "node_modules/@orange-js/vite/dist/entrypoints/entry.ssr.js", 58 | ), 59 | // rsc: entrypoint( 60 | // "entry.rsc", 61 | // "node_modules/@orange-js/vite/dist/entrypoints/entry.rsc.js" 62 | // ), 63 | }, 64 | serverHandler: false, 65 | loadModuleDevProxy: true, 66 | }), 67 | preserveClassNames(), 68 | // @ts-ignore 69 | cloudflare( 70 | options.cloudflare ?? { 71 | configPath: "./wrangler.jsonc", 72 | viteEnvironment: { 73 | name: "rsc", 74 | }, 75 | }, 76 | ), 77 | routesPlugin(config), 78 | routeReload((newConfig) => { 79 | _config = newConfig; 80 | }), 81 | ]; 82 | } 83 | 84 | function entrypoint(name: string, fallback: string) { 85 | for (const extension of ["tsx", "jsx"]) { 86 | const entrypoint = path.join( 87 | process.cwd(), 88 | "src", 89 | "entrypoints", 90 | `${name}.${extension}`, 91 | ); 92 | if (fs.existsSync(entrypoint)) { 93 | return entrypoint; 94 | } 95 | } 96 | 97 | return fallback; 98 | } 99 | -------------------------------------------------------------------------------- /packages/cli/src/commands/provision/kv.ts: -------------------------------------------------------------------------------- 1 | import c from "chalk"; 2 | import { Cloudflare } from "cloudflare"; 3 | import { 4 | unstable_readConfig as readWranglerConfig, 5 | experimental_patchConfig as patchConfig, 6 | } from "wrangler"; 7 | 8 | import { assertNotCancelled, text } from "../../prompts.js"; 9 | import { loader, step } from "../../prompts.js"; 10 | import { camelCase } from "./index.js"; 11 | import { generateWranglerTypes } from "../types.js"; 12 | 13 | export async function provisionKv(client: Cloudflare, accountId: string) { 14 | const config = readWranglerConfig({}); 15 | const existing = await loader(existingKvNames(client, accountId), { 16 | start: "Fetching existing KV namespaces...", 17 | success: (value) => `Found ${value.length} existing KV namespaces`, 18 | error: "Failed to fetch existing KV namespaces", 19 | }); 20 | 21 | const kvName = await text({ 22 | message: "Enter the name of the KV namespace", 23 | placeholder: "my-kv", 24 | validate(value) { 25 | if (value.length === 0) { 26 | return "KV name cannot be empty"; 27 | } 28 | 29 | if (existing.includes(value.toLowerCase())) { 30 | return `KV ${value} already exists`; 31 | } 32 | }, 33 | }); 34 | assertNotCancelled(kvName); 35 | 36 | const kv = await loader( 37 | client.kv.namespaces.create({ 38 | account_id: accountId, 39 | title: kvName, 40 | }), 41 | { 42 | start: "Creating KV namespace...", 43 | success: () => "KV namespace created", 44 | error: "Failed to create KV namespace", 45 | }, 46 | ); 47 | 48 | patchConfig( 49 | config.configPath!, 50 | { 51 | kv_namespaces: [{ binding: camelCase(kvName), id: kv.id }], 52 | }, 53 | true, 54 | ); 55 | 56 | await loader(generateWranglerTypes(), { 57 | start: "Generating Wrangler types...", 58 | success: () => "Generated Wrangler types", 59 | error: "Failed to generate Wrangler types", 60 | }); 61 | 62 | step( 63 | `Created KV namespace accessible via \`${c.dim( 64 | `env.${camelCase(kvName)}`, 65 | )}\``, 66 | ); 67 | } 68 | 69 | async function existingKvNames(client: Cloudflare, accountId: string) { 70 | const names: string[] = []; 71 | let existingKv = await client.kv.namespaces.list({ 72 | account_id: accountId, 73 | per_page: 100, 74 | }); 75 | 76 | names.push( 77 | ...existingKv.result 78 | .map((kv) => kv.title?.toLowerCase()) 79 | .filter((name) => name !== undefined), 80 | ); 81 | 82 | while (existingKv.result.length === 100) { 83 | existingKv = await client.kv.namespaces.list({ 84 | account_id: accountId, 85 | per_page: 100, 86 | page: existingKv.result.length / 100 + 1, 87 | }); 88 | 89 | names.push( 90 | ...existingKv.result 91 | .map((kv) => kv.title?.toLowerCase()) 92 | .filter((name) => name !== undefined), 93 | ); 94 | } 95 | 96 | return names; 97 | } 98 | -------------------------------------------------------------------------------- /packages/core/src/ssr.tsx: -------------------------------------------------------------------------------- 1 | // Adapted from: 2 | // https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.ssr.tsx 3 | 4 | import React, { ErrorInfo } from "react"; 5 | import { injectRSCPayload } from "rsc-html-stream/server"; 6 | import type { ReactFormState } from "react-dom/client"; 7 | import * as ReactClient from "@vitejs/plugin-rsc/ssr"; 8 | import * as ReactDOMServer from "react-dom/server.edge"; 9 | import type { RscPayload } from "./server.js"; 10 | import { ErrorFallback } from "./error-handling/browser.js"; 11 | 12 | export * from "./error-handling/browser.js"; 13 | 14 | export type RenderHTML = typeof renderHTML; 15 | 16 | export async function renderHTML( 17 | rscStream: ReadableStream, 18 | options?: { 19 | formState?: ReactFormState; 20 | nonce?: string; 21 | debugNojs?: boolean; 22 | onError?: (error: unknown, errorInfo: ErrorInfo) => void; 23 | }, 24 | ) { 25 | // duplicate one RSC stream into two. 26 | // - one for SSR (ReactClient.createFromReadableStream below) 27 | // - another for browser hydration payload by injecting . 28 | const [rscStream1, rscStream2] = rscStream.tee(); 29 | 30 | // deserialize RSC stream back to React VDOM 31 | let payload: Promise; 32 | function SsrRoot() { 33 | // deserialization needs to be kicked off inside ReactDOMServer context 34 | // for ReactDomServer preinit/preloading to work 35 | payload ??= ReactClient.createFromReadableStream(rscStream1); 36 | const { root } = React.use(payload); 37 | return root; 38 | } 39 | 40 | // render html (traditional SSR) 41 | const bootstrapScriptContent = 42 | await import.meta.viteRsc.loadBootstrapScriptContent("index"); 43 | const htmlStream = await ReactDOMServer.renderToReadableStream(, { 44 | bootstrapScriptContent: options?.debugNojs 45 | ? undefined 46 | : bootstrapScriptContent, 47 | nonce: options?.nonce, 48 | onError: options?.onError, 49 | // no types 50 | ...{ formState: options?.formState }, 51 | }); 52 | 53 | let responseStream: ReadableStream = htmlStream; 54 | if (!options?.debugNojs) { 55 | // initial RSC stream is injected in HTML stream as 56 | responseStream = responseStream.pipeThrough( 57 | injectRSCPayload(rscStream2, { 58 | nonce: options?.nonce, 59 | }), 60 | ); 61 | } 62 | 63 | return responseStream; 64 | } 65 | 66 | export async function renderErrorBoundaryResponse(opts: { 67 | message: string; 68 | stack?: string; 69 | }) { 70 | let error: Error | null = null; 71 | 72 | if (process.env.NODE_ENV === "development") { 73 | if (opts.stack) { 74 | error = new Error(opts.message, { 75 | // @ts-ignore 76 | stack: opts.stack, 77 | }); 78 | } else { 79 | error = new Error(opts.message); 80 | } 81 | } 82 | 83 | return await ReactDOMServer.renderToReadableStream( 84 | , 85 | {}, 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /packages/cli/src/commands/provision/durable-objects.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck - TODO(zebp): re-enable this once RSC is more mature 2 | import * as fs from "node:fs/promises"; 3 | import { flatRoutes } from "@react-router/fs-routes"; 4 | import { loadRoutes } from "@orange-js/vite/routes"; 5 | import { Config, resolveConfig } from "../../config.js"; 6 | import { Cloudflare } from "cloudflare"; 7 | import { loader, multiselect, error } from "../../prompts.js"; 8 | import { assertNotCancelled } from "../../prompts.js"; 9 | import { 10 | unstable_readConfig as readWranglerConfig, 11 | experimental_patchConfig as patchConfig, 12 | } from "wrangler"; 13 | import { generateWranglerTypes } from "../types.js"; 14 | 15 | export async function findDurableObjects(config: Config) { 16 | globalThis.__reactRouterAppDirectory = "app"; 17 | const routes = await flatRoutes(); 18 | const { manifest } = loadRoutes( 19 | routes, 20 | config.apiRoutePatterns ?? ["api*.{ts,js}"], 21 | ); 22 | 23 | const manifestEntries = Object.values(manifest); 24 | const fileContents = await Promise.all( 25 | manifestEntries.map((route) => fs.readFile(route.file, "utf-8")), 26 | ); 27 | 28 | const durableObjectNames = fileContents 29 | .map(durableObjectInCode) 30 | .filter((name): name is string => name !== undefined); 31 | 32 | return durableObjectNames; 33 | } 34 | 35 | function durableObjectInCode(contents: string): string | undefined { 36 | const matches = /export\s+class\s+(\w+)\s+extends\s+RouteDurableObject/.exec( 37 | contents, 38 | ); 39 | if (!matches || !matches[1]) { 40 | return undefined; 41 | } 42 | 43 | return matches[1]; 44 | } 45 | 46 | export async function provisionDurableObjects() { 47 | const config = await resolveConfig(); 48 | 49 | const wranglerConfig = readWranglerConfig({}); 50 | const durableObjects = await findDurableObjects(config); 51 | 52 | const provisionedDurableObjects = wranglerConfig.durable_objects.bindings.map( 53 | (binding) => binding.class_name, 54 | ); 55 | 56 | const nonProvisionedDurableObjects = durableObjects.filter( 57 | (durableObject) => !provisionedDurableObjects.includes(durableObject), 58 | ); 59 | 60 | if (nonProvisionedDurableObjects.length === 0) { 61 | error("No Durable Objects to provision."); 62 | return; 63 | } 64 | 65 | const provisionDurableObjects = await multiselect({ 66 | message: `${nonProvisionedDurableObjects.length} Durable Objects that need to be provisioned.`, 67 | options: nonProvisionedDurableObjects.map((durableObject) => ({ 68 | title: durableObject, 69 | value: durableObject, 70 | })), 71 | }); 72 | assertNotCancelled(provisionDurableObjects); 73 | 74 | patchConfig(wranglerConfig.configPath!, { 75 | durable_objects: { 76 | bindings: provisionDurableObjects.map((durableObject) => ({ 77 | class_name: durableObject, 78 | name: durableObject, 79 | })), 80 | }, 81 | migrations: [ 82 | { 83 | tag: `${new Date().toISOString()}-${provisionDurableObjects.join("-")}`, 84 | new_sqlite_classes: provisionDurableObjects, 85 | }, 86 | ], 87 | }); 88 | 89 | await loader(generateWranglerTypes(), { 90 | start: "Generating Wrangler types...", 91 | success: () => "Wrangler types generated", 92 | error: "Failed to generate Wrangler types", 93 | }); 94 | } 95 | -------------------------------------------------------------------------------- /.github/workflows/prerelease.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Creates 0.0.0-$COMMIT versions for every package and publishes them to NPM 3 | # 4 | name: Publish preleases to NPM 5 | on: 6 | pull_request: 7 | branches: 8 | - main 9 | permissions: 10 | contents: read 11 | pull-requests: write 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: pnpm/action-setup@v3 18 | with: 19 | version: 10 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: "23.x" 23 | registry-url: "https://registry.npmjs.org" 24 | - run: pnpm install --frozen-lockfile 25 | - run: pnpm recursive run --sort --workspace-concurrency=1 build 26 | - name: Write dev versions 27 | env: 28 | PR_SHA: ${{ github.event.pull_request.head.sha }} 29 | run: node scripts/version-for-prerelease.ts 30 | 31 | - name: Publish core 32 | run: pnpm publish --no-git-checks --tag dev --access public 33 | working-directory: packages/core 34 | env: 35 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | 37 | - name: Publish actors 38 | run: pnpm publish --no-git-checks --tag dev --access public 39 | working-directory: packages/actors 40 | env: 41 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | 43 | - name: Publish vite 44 | run: pnpm publish --no-git-checks --tag dev --access public 45 | working-directory: packages/vite 46 | env: 47 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 48 | 49 | - name: Publish create-orange 50 | run: pnpm publish --no-git-checks --tag dev --access public 51 | working-directory: packages/create-orange 52 | env: 53 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 54 | 55 | - name: Publish cli 56 | run: pnpm publish --no-git-checks --tag dev --access public 57 | working-directory: packages/cli 58 | env: 59 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 60 | 61 | - name: Leave PR comment with install instructions 62 | if: github.event_name == 'pull_request' 63 | uses: actions/github-script@v6 64 | with: 65 | github-token: ${{ secrets.GITHUB_TOKEN }} 66 | script: | 67 | const sha = context.payload.pull_request.head.sha.substring(0, 7); 68 | const comment = `## Prerelease Packages Available 69 | 70 | Install the prerelease packages with: 71 | \`\`\`bash 72 | npm install @orange-js/orange@0.0.0-${sha} @orange-js/actors@0.0.0-${sha} && npm install -D @orange-js/vite@0.0.0-${sha} @orange-js/cli@0.0.0-${sha} 73 | pnpm add @orange-js/orange@0.0.0-${sha} @orange-js/actors@0.0.0-${sha} && pnpm add -D @orange-js/vite@0.0.0-${sha} @orange-js/cli@0.0.0-${sha} 74 | yarn add @orange-js/orange@0.0.0-${sha} @orange-js/actors@0.0.0-${sha} && yarn add -D @orange-js/vite@0.0.0-${sha} @orange-js/cli@0.0.0-${sha} 75 | bun add @orange-js/orange@0.0.0-${sha} @orange-js/actors@0.0.0-${sha} && bun add -D @orange-js/vite@0.0.0-${sha} @orange-js/cli@0.0.0-${sha} 76 | \`\`\``; 77 | 78 | github.rest.issues.createComment({ 79 | issue_number: context.issue.number, 80 | owner: context.repo.owner, 81 | repo: context.repo.repo, 82 | body: comment 83 | }); 84 | -------------------------------------------------------------------------------- /packages/cli/src/commands/provision/object-storage.ts: -------------------------------------------------------------------------------- 1 | import c from "chalk"; 2 | import { Cloudflare } from "cloudflare"; 3 | import { 4 | unstable_readConfig as readWranglerConfig, 5 | experimental_patchConfig as patchConfig, 6 | } from "wrangler"; 7 | 8 | import { 9 | assertNotCancelled, 10 | loader, 11 | select, 12 | step, 13 | text, 14 | } from "../../prompts.js"; 15 | import { camelCase } from "./index.js"; 16 | import { generateWranglerTypes } from "../types.js"; 17 | export async function provisionBucket(client: Cloudflare, accountId: string) { 18 | const config = readWranglerConfig({}); 19 | const existing = await loader(existingBucketNames(client, accountId), { 20 | start: "Fetching existing buckets...", 21 | success: (value) => `Found ${value.length} existing buckets`, 22 | error: "Failed to fetch existing buckets", 23 | }); 24 | 25 | const bucketName = await text({ 26 | message: "Enter the name of the bucket", 27 | placeholder: "my-bucket", 28 | validate(value) { 29 | if (existing.includes(value.toLowerCase())) { 30 | return `Bucket ${value} already exists`; 31 | } 32 | }, 33 | }); 34 | assertNotCancelled(bucketName); 35 | 36 | const jurisdiction = await select({ 37 | message: "Select the jurisdiction of the bucket", 38 | options: [ 39 | { title: "Automatic", value: undefined }, 40 | { title: "EU", value: "eu" }, 41 | { title: "FedRAMP", value: "fedramp" }, 42 | ], 43 | }); 44 | assertNotCancelled(jurisdiction); 45 | 46 | const bucket = await loader( 47 | client.r2.buckets.create({ 48 | account_id: accountId, 49 | name: bucketName, 50 | jurisdiction, 51 | }), 52 | { 53 | start: "Creating bucket...", 54 | success: () => "Bucket created", 55 | error: "Failed to create bucket", 56 | }, 57 | ); 58 | 59 | patchConfig( 60 | config.configPath!, 61 | { 62 | r2_buckets: [ 63 | { 64 | binding: camelCase(bucketName), 65 | bucket_name: bucketName, 66 | jurisdiction, 67 | }, 68 | ], 69 | }, 70 | true, 71 | ); 72 | 73 | await loader(generateWranglerTypes(), { 74 | start: "Generating Wrangler types...", 75 | success: () => "Generated Wrangler types", 76 | error: "Failed to generate Wrangler types", 77 | }); 78 | 79 | step( 80 | `Created R2 bucket accessible via \`${c.dim( 81 | `env.${camelCase(bucketName)}`, 82 | )}\``, 83 | ); 84 | } 85 | 86 | async function existingBucketNames(client: Cloudflare, accountId: string) { 87 | const names: string[] = []; 88 | let existingBuckets = await client.r2.buckets.list({ 89 | account_id: accountId, 90 | per_page: 100, 91 | }); 92 | 93 | const buckets = existingBuckets.buckets 94 | ?.map((bucket) => bucket.name?.toLowerCase()) 95 | ?.filter((name) => name !== undefined); 96 | 97 | names.push(...(buckets ?? [])); 98 | 99 | while (existingBuckets.buckets?.length === 100) { 100 | existingBuckets = await client.r2.buckets.list({ 101 | account_id: accountId, 102 | per_page: 100, 103 | cursor: existingBuckets.buckets[existingBuckets.buckets.length - 1].name, 104 | }); 105 | 106 | const buckets = existingBuckets.buckets 107 | ?.map((bucket) => bucket.name?.toLowerCase()) 108 | ?.filter((name) => name !== undefined); 109 | 110 | names.push(...(buckets ?? [])); 111 | } 112 | 113 | return names; 114 | } 115 | -------------------------------------------------------------------------------- /packages/cli/src/commands/types.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import chalk from "chalk"; 3 | import { createCommand } from "@commander-js/extra-typings"; 4 | import { ResolvedConfig, resolveConfig } from "@orange-js/vite/config"; 5 | import { mkdir, writeFile } from "node:fs/promises"; 6 | import { dirname } from "node:path"; 7 | 8 | import { exec } from "../exec.js"; 9 | import { step } from "../prompts.js"; 10 | 11 | export const typesCommand = createCommand("types") 12 | .description("Generate TypeScript types for Cloudflare Workers") 13 | .option("--no-wrangler", "Skip generating Wrangler types") 14 | .action(async (options) => { 15 | const config = await resolveConfig(); 16 | 17 | if (options.wrangler) { 18 | step("Generating Wrangler types..."); 19 | await generateWranglerTypes(); 20 | } 21 | 22 | await generateRouteTypes(config); 23 | }); 24 | 25 | export async function generateWranglerTypes() { 26 | await exec("wrangler", ["types"]); 27 | } 28 | 29 | async function generateRouteTypes(config: ResolvedConfig) { 30 | const routes = config.routes; 31 | 32 | for (const route of routes) { 33 | step(`Generating route types for ${chalk.whiteBright(route.file)}`, true); 34 | const newPath = route.file.replace(".tsx", ".ts").replace("app", ".types"); 35 | 36 | const slashes = newPath.split("/").length - 1; 37 | const importPrefix = "../".repeat(slashes); 38 | const params = paramsForPath(route.pattern); 39 | const paramsLiteral = `{ ${params 40 | .map((param) => `"${param}": string`) 41 | .join(", ")} }`; 42 | 43 | await mkdir(dirname(newPath), { recursive: true }); 44 | await writeFile( 45 | newPath, 46 | dedent` 47 | import type * as T from "@orange-js/orange/route-module" 48 | 49 | type Module = typeof import("${importPrefix}${route.file 50 | .replace(".tsx", "") 51 | .replace(".jsx", "")}") 52 | 53 | export type Info = { 54 | parents: [], 55 | params: ${paramsLiteral} & { [key: string]: string | undefined } 56 | module: Module 57 | loaderData: T.CreateLoaderData 58 | actionData: T.CreateActionData 59 | } 60 | 61 | export namespace Route { 62 | export type LinkDescriptors = T.LinkDescriptors; 63 | export type LinksFunction = () => LinkDescriptors; 64 | 65 | export type MetaArgs = T.MetaArgs 66 | export type MetaDescriptors = T.MetaDescriptors 67 | export type MetaFunction = (args: MetaArgs) => MetaDescriptors 68 | 69 | export type HeadersArgs = T.HeadersArgs; 70 | export type HeadersFunction = (args: HeadersArgs) => Headers | HeadersInit; 71 | 72 | export type LoaderArgs = T.LoaderArgs; 73 | export type ClientLoaderArgs = T.ClientLoaderArgs; 74 | export type ActionArgs = T.ActionArgs; 75 | export type ClientActionArgs = T.ClientActionArgs; 76 | 77 | export type Component = T.Component; 78 | export type ComponentProps = T.ComponentProps; 79 | export type ErrorBoundaryProps = T.ErrorBoundaryProps; 80 | export type HydrateFallbackProps = T.HydrateFallbackProps; 81 | } 82 | `, 83 | { 84 | encoding: "utf-8", 85 | }, 86 | ); 87 | } 88 | } 89 | 90 | function paramsForPath(path: string) { 91 | const params: string[] = []; 92 | const segments = path.split("/"); 93 | 94 | for (const segment of segments) { 95 | if (segment.startsWith(":")) { 96 | const paramName = segment.slice(1); 97 | params.push(paramName); 98 | } 99 | } 100 | 101 | return params; 102 | } 103 | -------------------------------------------------------------------------------- /packages/cli/src/commands/provision/index.ts: -------------------------------------------------------------------------------- 1 | import { Cloudflare } from "cloudflare"; 2 | import { createCommand } from "@commander-js/extra-typings"; 3 | import { readFileSync } from "node:fs"; 4 | import { join } from "node:path"; 5 | 6 | import { assertNotCancelled, select } from "../../prompts.js"; 7 | import { readAccountId } from "../../wrangler.js"; 8 | import { provisionPostgres } from "./postgres.js"; 9 | import { provisionSqlite } from "./sqlite.js"; 10 | import { provisionBucket } from "./object-storage.js"; 11 | // import { provisionDurableObjects } from "./durable-objects.js"; 12 | import { provisionKv } from "./kv.js"; 13 | 14 | export function provisionCommand(client: Cloudflare) { 15 | return ( 16 | createCommand("provision") 17 | .description("Provision Cloudflare resources for your project") 18 | // .option("-d, --durable-objects", "Provision Durable Objects") 19 | .option("-k, --kv", "Provision Key-Value Store") 20 | .option("-s, --sqlite", "Provision SQLite Database") 21 | .option("-D, --d1", "Provision D1 Database") 22 | .option("-b, --bucket", "Provision Object Storage Bucket") 23 | .option("-R, --r2", "Provision R2 Bucket") 24 | .option("-p, --postgres", "Provision Postgres Database") 25 | .action(async (options) => { 26 | const accountId = await readAccountId(client); 27 | const selectedResource = await determineResource(options); 28 | 29 | if (selectedResource === "postgres") { 30 | await provisionPostgres(client, accountId); 31 | } else if (selectedResource === "sqlite") { 32 | await provisionSqlite(client, accountId); 33 | } else if (selectedResource === "bucket") { 34 | await provisionBucket(client, accountId); 35 | } else if (selectedResource === "kv") { 36 | await provisionKv(client, accountId); 37 | } else if (selectedResource === "durable-objects") { 38 | // await provisionDurableObjects(); 39 | } 40 | }) 41 | ); 42 | } 43 | 44 | async function determineResource(options: { 45 | // durableObjects?: true; 46 | kv?: true; 47 | sqlite?: true; 48 | d1?: true; 49 | bucket?: true; 50 | r2?: true; 51 | postgres?: true; 52 | }): Promise { 53 | // if (options.durableObjects) { 54 | // return "durable-objects"; 55 | // } 56 | 57 | if (options.kv) { 58 | return "kv"; 59 | } 60 | 61 | if (options.sqlite || options.d1) { 62 | return "sqlite"; 63 | } 64 | 65 | if (options.bucket || options.r2) { 66 | return "bucket"; 67 | } 68 | 69 | if (options.postgres) { 70 | return "postgres"; 71 | } 72 | 73 | const resources = [ 74 | { title: "Object Storage Bucket", value: "bucket" }, 75 | { title: "Key-Value Store", value: "kv" }, 76 | { title: "SQLite Database", value: "sqlite" }, 77 | { title: "Postgres Database", value: "postgres" }, 78 | // { title: "Durable Objects", value: "durable-objects" }, 79 | ]; 80 | 81 | const selectedResource = await select({ 82 | message: "Select a resource to provision", 83 | options: resources, 84 | }); 85 | assertNotCancelled(selectedResource); 86 | 87 | return selectedResource; 88 | } 89 | 90 | export function camelCase(str: string) { 91 | return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); 92 | } 93 | 94 | export function installedNodeModules(): string[] { 95 | const packageJson = readFileSync(join(process.cwd(), "package.json"), "utf8"); 96 | const json = JSON.parse(packageJson); 97 | return json.dependencies ? Object.keys(json.dependencies) : []; 98 | } 99 | 100 | export function isDrizzleInstalled() { 101 | const installed = installedNodeModules(); 102 | return installed.includes("drizzle-orm"); 103 | } 104 | -------------------------------------------------------------------------------- /packages/actors/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ActorState, Actor as CfActor, getActor } from "@cloudflare/actors"; 3 | import { isValidElement } from "react"; 4 | import { observedSymbol } from "./observed.js"; 5 | import { 6 | createFromReadableStream, 7 | renderToReadableStream, 8 | } from "@vitejs/plugin-rsc/rsc"; 9 | 10 | export * from "@cloudflare/actors"; 11 | 12 | export * from "./observed.js"; 13 | 14 | type RSCPayload = { root: React.ReactNode }; 15 | 16 | export class Actor extends CfActor { 17 | Component( 18 | props: Record, 19 | ): React.ReactNode | Promise { 20 | return null; 21 | } 22 | 23 | async __rscStream( 24 | name: string, 25 | props: Record, 26 | ): Promise<[ReadableStream, boolean]> { 27 | const Component = (this[name as keyof this] as any).bind(this); 28 | const rscStream = renderToReadableStream({ 29 | root: , 30 | }); 31 | // @ts-ignore 32 | const observed = this[observedSymbol] ?? false; 33 | return [rscStream, observed]; 34 | } 35 | 36 | static Component = Component; 37 | 38 | static { 39 | Object.defineProperty(this, "__orangeIsActor", { 40 | value: true, 41 | enumerable: false, 42 | }); 43 | } 44 | } 45 | 46 | async function internalComponent, Env>( 47 | props: { 48 | actor: ActorConstructor; 49 | name?: string; 50 | } & PropsFromDurableObject, 51 | ) { 52 | if ("children" in props) { 53 | throw new Error("Children are not currently supported"); 54 | } 55 | 56 | for (const key in props) { 57 | if (key === "actor") { 58 | continue; 59 | } 60 | 61 | const value = props[key as keyof typeof props]; 62 | if (typeof value === "function") { 63 | throw new Error("Functions are not currently supported"); 64 | } 65 | 66 | if (isValidElement(value)) { 67 | throw new Error("React components are not currently supported"); 68 | } 69 | } 70 | 71 | const stub = getActor(props.actor, props.name ?? "default"); 72 | const { actor, ...rest } = props; 73 | 74 | // TODO: Remove this hack once the data-race in actors is fixed 75 | await stub.setIdentifier(props.name ?? "default"); 76 | 77 | const rscStream = await stub.__rscStream("Component", rest); 78 | const payload = await createFromReadableStream( 79 | rscStream[0] as ReadableStream, 80 | ); 81 | 82 | return { 83 | root: payload.root, 84 | isObserved: rscStream[1], 85 | }; 86 | } 87 | 88 | type PropsFromDurableObject< 89 | T extends Actor, 90 | Env, 91 | K extends keyof T, 92 | > = T[K] extends (arg: infer Z) => React.ReactNode | Promise 93 | ? Z 94 | : never; 95 | 96 | export type ActorConstructor = Actor> = new ( 97 | state: ActorState, 98 | env: any, 99 | ) => T; 100 | 101 | export { getActor } from "@cloudflare/actors"; 102 | 103 | async function Component, Env>( 104 | props: { 105 | actor: ActorConstructor; 106 | name?: string; 107 | } & PropsFromDurableObject, 108 | ) { 109 | const { root, isObserved } = await internalComponent(props); 110 | 111 | let ClientComponent: React.ComponentType; 112 | 113 | // With Vite's pre-bundling in dev mode esbuild will bundle this dependency into 114 | // a single ES module, but since we need the client module to be "use client" we 115 | // need to ensure that it isn't bundled into a single module. 116 | if (process.env.NODE_ENV === "development") { 117 | // We need to ensure TS doesn't actually resolve this import to prevent it 118 | // from refusing to emit this file. 119 | ClientComponent = (await import("@orange-js" + "/actors/client")) 120 | .ClientComponent; 121 | } else { 122 | ClientComponent = (await import("./client.js")).ClientComponent; 123 | } 124 | 125 | if (isObserved) { 126 | return ( 127 | 131 | {root} 132 | 133 | ); 134 | } 135 | 136 | return root; 137 | } 138 | -------------------------------------------------------------------------------- /packages/core/src/client.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors 2 | // Source: https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.browser.tsx 3 | 4 | import React from "react"; 5 | import * as ReactClient from "@vitejs/plugin-rsc/browser"; 6 | import { rscStream } from "rsc-html-stream/client"; 7 | import * as ReactDOMClient from "react-dom/client"; 8 | import type { RscPayload } from "./server.js"; 9 | import { ErrorBoundary, ErrorFallback } from "./error-handling/browser.js"; 10 | 11 | export async function main() { 12 | // stash `setPayload` function to trigger re-rendering 13 | // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) 14 | let setPayload: (v: RscPayload) => void; 15 | 16 | // deserialize RSC stream back to React VDOM for CSR 17 | const initialPayload = await ReactClient.createFromReadableStream( 18 | // initial RSC stream is injected in SSR stream as 19 | rscStream, 20 | ); 21 | 22 | // browser root component to (re-)render RSC payload as state 23 | function BrowserRoot() { 24 | const [payload, setPayload_] = React.useState(initialPayload); 25 | 26 | React.useEffect(() => { 27 | setPayload = (v) => React.startTransition(() => setPayload_(v)); 28 | }, [setPayload_]); 29 | 30 | // re-fetch/render on client side navigation 31 | React.useEffect(() => { 32 | return listenNavigation(() => fetchRscPayload()); 33 | }, []); 34 | 35 | return payload.root; 36 | } 37 | 38 | // re-fetch RSC and trigger re-rendering 39 | async function fetchRscPayload() { 40 | const payload = await ReactClient.createFromFetch( 41 | fetch(window.location.href), 42 | ); 43 | setPayload(payload); 44 | } 45 | 46 | // register a handler which will be internally called by React 47 | // on server function request after hydration. 48 | ReactClient.setServerCallback(async (id, args) => { 49 | const url = new URL(window.location.href); 50 | const temporaryReferences = ReactClient.createTemporaryReferenceSet(); 51 | const payload = await ReactClient.createFromFetch( 52 | fetch(url, { 53 | method: "POST", 54 | body: await ReactClient.encodeReply(args, { temporaryReferences }), 55 | headers: { 56 | "x-rsc-action": id, 57 | }, 58 | }), 59 | { temporaryReferences }, 60 | ); 61 | setPayload(payload); 62 | return payload.returnValue; 63 | }); 64 | 65 | // hydration 66 | const browserRoot = ( 67 | 68 | }> 69 | 70 | 71 | 72 | ); 73 | ReactDOMClient.hydrateRoot(document, browserRoot, { 74 | formState: initialPayload.formState, 75 | }); 76 | 77 | // implement server HMR by trigering re-fetch/render of RSC upon server code change 78 | if (import.meta.hot) { 79 | import.meta.hot.on("rsc:update", () => { 80 | fetchRscPayload(); 81 | }); 82 | } 83 | } 84 | 85 | // a little helper to setup events interception for client side navigation 86 | function listenNavigation(onNavigation: () => void) { 87 | window.addEventListener("popstate", onNavigation); 88 | 89 | const oldPushState = window.history.pushState; 90 | window.history.pushState = function (...args) { 91 | const res = oldPushState.apply(this, args); 92 | onNavigation(); 93 | return res; 94 | }; 95 | 96 | const oldReplaceState = window.history.replaceState; 97 | window.history.replaceState = function (...args) { 98 | const res = oldReplaceState.apply(this, args); 99 | onNavigation(); 100 | return res; 101 | }; 102 | 103 | function onClick(e: MouseEvent) { 104 | let link = (e.target as Element).closest("a"); 105 | if ( 106 | link && 107 | link instanceof HTMLAnchorElement && 108 | link.href && 109 | (!link.target || link.target === "_self") && 110 | link.origin === location.origin && 111 | !link.hasAttribute("download") && 112 | e.button === 0 && // left clicks only 113 | !e.metaKey && // open in new tab (mac) 114 | !e.ctrlKey && // open in new tab (windows) 115 | !e.altKey && // download 116 | !e.shiftKey && 117 | !e.defaultPrevented 118 | ) { 119 | e.preventDefault(); 120 | history.pushState(null, "", link.href); 121 | } 122 | } 123 | document.addEventListener("click", onClick); 124 | 125 | return () => { 126 | document.removeEventListener("click", onClick); 127 | window.removeEventListener("popstate", onNavigation); 128 | window.history.pushState = oldPushState; 129 | window.history.replaceState = oldReplaceState; 130 | }; 131 | } 132 | -------------------------------------------------------------------------------- /e2e/actors.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, wranglerJson } from "./fixture/index"; 2 | 3 | const baseFiles = { 4 | "wrangler.jsonc": wranglerJson({ 5 | durable_objects: { 6 | bindings: [ 7 | { 8 | name: "Test", 9 | class_name: "Test", 10 | }, 11 | ], 12 | }, 13 | migrations: [ 14 | { 15 | tag: "v1", 16 | new_sqlite_classes: ["Test"], 17 | }, 18 | ], 19 | }), 20 | "app/entry.server.tsx": ` 21 | import { app } from "@orange-js/orange/server"; 22 | import { Root } from "./root"; 23 | 24 | export { Test } from "./routes/index.tsx"; 25 | 26 | export default app(Root); 27 | `, 28 | }; 29 | 30 | test.multi( 31 | "can call actor method", 32 | async ({ page, port }) => { 33 | await page.goto(`http://localhost:${port}`); 34 | await expect(page.getByText("Hello from Actor")).toBeVisible(); 35 | }, 36 | { 37 | ...baseFiles, 38 | "app/routes/index.tsx": ` 39 | import { Actor, getActor } from "@orange-js/actors"; 40 | 41 | export class Test extends Actor { 42 | async load() { 43 | return { 44 | message: "Hello from Actor", 45 | }; 46 | } 47 | } 48 | 49 | export default async function Home() { 50 | const stub = getActor(Test, "foo"); 51 | const { message } = await stub.load(); 52 | return
{message}
; 53 | } 54 | `, 55 | } 56 | ); 57 | 58 | test.multi( 59 | "using actor component", 60 | async ({ page, port }) => { 61 | await page.goto(`http://localhost:${port}`); 62 | await expect(page.getByText("Hello from Actor")).toBeVisible(); 63 | await expect(page.getByText("id: foo")).toBeVisible(); 64 | }, 65 | { 66 | ...baseFiles, 67 | "app/routes/index.tsx": ` 68 | import { Actor, getActor } from "@orange-js/actors"; 69 | 70 | export class Test extends Actor { 71 | async Component() { 72 | return ( 73 |
74 | Hello from Actor 75 | id: {this.identifier} 76 |
77 | ); 78 | } 79 | } 80 | 81 | export default async function Home() { 82 | return ; 83 | } 84 | `, 85 | } 86 | ); 87 | 88 | test.multi( 89 | "actor as component", 90 | async ({ page, port }) => { 91 | await page.goto(`http://localhost:${port}/?id=foo`); 92 | await expect(page.getByText("Hello from Actor")).toBeVisible(); 93 | await expect(page.getByText("id: foo")).toBeVisible(); 94 | }, 95 | { 96 | ...baseFiles, 97 | "app/routes/index.tsx": ` 98 | import { Actor, getActor } from "@orange-js/actors"; 99 | 100 | export default class Test extends Actor { 101 | static nameFromRequest(request: Request) { 102 | const url = new URL(request.url); 103 | return url.searchParams.get("id") ?? "default"; 104 | } 105 | 106 | async Component() { 107 | return ( 108 |
109 | Hello from Actor 110 | id: {this.identifier} 111 |
112 | ); 113 | } 114 | } 115 | `, 116 | "app/entry.server.tsx": ` 117 | import { app } from "@orange-js/orange/server"; 118 | import { Root } from "./root"; 119 | 120 | export { default as Test } from "./routes/index.tsx"; 121 | 122 | export default app(Root); 123 | `, 124 | } 125 | ); 126 | 127 | test.multi( 128 | "multiplayer", 129 | async ({ page, port, browser }) => { 130 | const secondTab = await browser.newPage(); 131 | await secondTab.goto(`http://localhost:${port}/`); 132 | await expect(secondTab.getByText("count: 0")).toBeVisible(); 133 | 134 | await page.goto(`http://localhost:${port}/`); 135 | await expect(page.getByText("count: 0")).toBeVisible(); 136 | 137 | // Increment and ensure both tabs update 138 | await page.click("button"); 139 | 140 | await expect(secondTab.getByText("count: 1")).toBeVisible({ 141 | timeout: 1000, 142 | }); 143 | await expect(page.getByText("count: 1")).toBeVisible({ 144 | timeout: 1000, 145 | }); 146 | }, 147 | { 148 | ...baseFiles, 149 | "app/routes/index.tsx": ` 150 | import { Actor, getActor, Observed, Persist } from "@orange-js/actors"; 151 | 152 | export class Test extends Actor { 153 | @Persist 154 | count = 0; 155 | 156 | async increment() { 157 | this.count++; 158 | } 159 | 160 | @Observed("count") 161 | async Component() { 162 | return ( 163 |
164 | count: {this.count} 165 |
166 | ); 167 | } 168 | } 169 | 170 | async function increment() { 171 | "use server"; 172 | Test.get("foo")!.increment(); 173 | } 174 | 175 | export default function Home() { 176 | return ( 177 |
178 | 179 | 180 |
181 | ); 182 | } 183 | `, 184 | "app/entry.server.tsx": ` 185 | import { app } from "@orange-js/orange/server"; 186 | import { Root } from "./root"; 187 | 188 | export { Test } from "./routes/index.tsx"; 189 | 190 | export default app(Root); 191 | `, 192 | } 193 | ); 194 | -------------------------------------------------------------------------------- /packages/cli/src/commands/provision/sqlite.ts: -------------------------------------------------------------------------------- 1 | import c from "chalk"; 2 | import dedent from "dedent"; 3 | import { Cloudflare } from "cloudflare"; 4 | import { 5 | unstable_readConfig as readWranglerConfig, 6 | experimental_patchConfig as patchConfig, 7 | } from "wrangler"; 8 | import { writeFileSync } from "node:fs"; 9 | import { join } from "node:path"; 10 | 11 | import { 12 | warn, 13 | text, 14 | loader, 15 | assertNotCancelled, 16 | select, 17 | step, 18 | log, 19 | orange, 20 | } from "../../prompts.js"; 21 | import { camelCase, isDrizzleInstalled } from "./index.js"; 22 | import { generateWranglerTypes } from "../types.js"; 23 | 24 | export async function provisionSqlite(client: Cloudflare, accountId: string) { 25 | const config = readWranglerConfig({}); 26 | const existing = await loader(existingDatabaseNames(client, accountId), { 27 | start: "Fetching existing databases...", 28 | success: (value) => `Found ${value.length} existing databases`, 29 | error: "Failed to fetch existing databases", 30 | }); 31 | 32 | const sqliteName = await text({ 33 | message: "Enter the name of the SQLite database", 34 | placeholder: "my-database", 35 | validate(value) { 36 | if (value.length === 0) { 37 | return "Database name cannot be empty"; 38 | } 39 | 40 | if (existing.includes(value.toLowerCase())) { 41 | return `Database ${value} already exists`; 42 | } 43 | }, 44 | }); 45 | assertNotCancelled(sqliteName); 46 | 47 | const locationHint = await select< 48 | "wnam" | "enam" | "weur" | "eeur" | "apac" | "oc" | undefined 49 | >({ 50 | message: "Do you want a primary region hint for your database?", 51 | options: [ 52 | { title: "Automatic", value: undefined }, 53 | { title: "US East", value: "wnam" }, 54 | { title: "US West", value: "enam" }, 55 | { title: "EU West", value: "weur" }, 56 | { title: "EU East", value: "eeur" }, 57 | { title: "Asia Pacific", value: "apac" }, 58 | { title: "Oceania", value: "oc" }, 59 | ], 60 | }); 61 | assertNotCancelled(locationHint); 62 | 63 | const database = await loader( 64 | client.d1.database.create({ 65 | account_id: accountId, 66 | name: sqliteName, 67 | primary_location_hint: locationHint, 68 | }), 69 | { 70 | start: "Creating database...", 71 | success: () => "Database created", 72 | error: "Failed to create database", 73 | }, 74 | ); 75 | 76 | const extraDbConfig = isDrizzleInstalled() 77 | ? { 78 | migrations_dir: "drizzle/migrations", 79 | } 80 | : {}; 81 | 82 | patchConfig( 83 | config.configPath!, 84 | { 85 | d1_databases: [ 86 | { 87 | binding: camelCase(sqliteName), 88 | database_name: sqliteName, 89 | database_id: database.uuid, 90 | ...extraDbConfig, 91 | }, 92 | ], 93 | }, 94 | true, 95 | ); 96 | 97 | await loader(generateWranglerTypes(), { 98 | start: "Generating Wrangler types...", 99 | success: () => "Generated Wrangler types", 100 | error: "Failed to generate Wrangler types", 101 | }); 102 | 103 | if (isDrizzleInstalled()) { 104 | const databaseFile = databaseFileTemplate( 105 | sqliteName, 106 | [ 107 | 'import { drizzle } from "drizzle-orm/d1";', 108 | 'import * as schema from "./schema.server";', 109 | ], 110 | `drizzle(env.${camelCase(sqliteName)}, { schema })`, 111 | ); 112 | 113 | writeFileSync(join(process.cwd(), "app/database.server.ts"), databaseFile); 114 | 115 | log( 116 | c.dim("You'll need to create a schema file in your app directory."), 117 | c.dim("See more at https://orm.drizzle.team/docs/sql-schema-declaration"), 118 | ); 119 | 120 | step( 121 | `Created SQLite database accessible via \`${c.dim( 122 | `context.${camelCase(sqliteName)}`, 123 | )}\``, 124 | ); 125 | 126 | warn( 127 | dedent` 128 | Add \`${orange("database")}\` from \`${orange( 129 | "app/database.context.ts", 130 | )}\` to your entrypoint defined in \`${orange("app/entry.server.tsx")}\` 131 | See more at ${orange("https://orange-js.dev/docs/context")} 132 | `.trim(), 133 | ); 134 | } else { 135 | step( 136 | `Created SQLite database accessible via \`${c.dim( 137 | `env.${camelCase(sqliteName)}`, 138 | )}\``, 139 | ); 140 | } 141 | } 142 | 143 | async function existingDatabaseNames(client: Cloudflare, accountId: string) { 144 | const names: string[] = []; 145 | let existingDatabases = await client.d1.database.list({ 146 | account_id: accountId, 147 | per_page: 100, 148 | }); 149 | 150 | names.push( 151 | ...existingDatabases.result 152 | .map((db) => db.name?.toLowerCase()) 153 | .filter((name) => name !== undefined), 154 | ); 155 | 156 | while (existingDatabases.result.length === 100) { 157 | existingDatabases = await client.d1.database.list({ 158 | account_id: accountId, 159 | per_page: 100, 160 | page: existingDatabases.result.length / 100 + 1, 161 | }); 162 | 163 | names.push( 164 | ...existingDatabases.result 165 | .map((db) => db.name?.toLowerCase()) 166 | .filter((name) => name !== undefined), 167 | ); 168 | } 169 | 170 | return names; 171 | } 172 | 173 | function databaseFileTemplate( 174 | databaseName: string, 175 | imports: string | string[], 176 | databaseExpr: string, 177 | ) { 178 | return dedent` 179 | import { env } from "cloudflare:workers"; 180 | ${Array.isArray(imports) ? imports.join("\n") : imports} 181 | 182 | declare module "@orange-js/orange" { 183 | // Add the database to the context. 184 | interface Context extends ContextFrom {} 185 | } 186 | 187 | export async function database() { 188 | return { 189 | ${camelCase(databaseName)}: ${databaseExpr}, 190 | }; 191 | } 192 | `; 193 | } 194 | -------------------------------------------------------------------------------- /e2e/hono-handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "./fixture/index"; 2 | 3 | test.multi( 4 | "hono handler with React routing", 5 | async ({ page, port }) => { 6 | await page.goto(`http://localhost:${port}`); 7 | await expect(page.getByText("Hello World")).toBeVisible(); 8 | }, 9 | { 10 | "app/routes/index.tsx": ` 11 | export default async function Index() { 12 | return
Hello World
; 13 | } 14 | `, 15 | "app/entry.server.tsx": ` 16 | import { Hono } from "hono"; 17 | import { handler } from "@orange-js/orange/hono"; 18 | import { Root } from "./root"; 19 | 20 | const app = new Hono(); 21 | 22 | // Regular Hono route that should work alongside React 23 | app.get("/api/status", (c) => c.json({ status: "ok" })); 24 | 25 | // Use the handler to create middleware for React routing 26 | app.use("*", handler(Root)); 27 | 28 | export default app; 29 | `, 30 | } 31 | ); 32 | 33 | test.multi( 34 | "hono handler with regular API routing", 35 | async ({ page, port }) => { 36 | await page.goto(`http://localhost:${port}/api/status`); 37 | await expect(page.getByText('{"status":"ok"}')).toBeVisible(); 38 | }, 39 | { 40 | "app/routes/index.tsx": ` 41 | export default async function Index() { 42 | return
Hello World
; 43 | } 44 | `, 45 | "app/entry.server.tsx": ` 46 | import { Hono } from "hono"; 47 | import { handler } from "@orange-js/orange/hono"; 48 | import { Root } from "./root"; 49 | 50 | const app = new Hono(); 51 | 52 | // Regular Hono route that should work alongside React 53 | app.get("/api/status", (c) => c.json({ status: "ok" })); 54 | 55 | // Use the handler to create middleware for React routing 56 | app.use("*", handler(Root)); 57 | 58 | export default app; 59 | `, 60 | } 61 | ); 62 | 63 | test.multi( 64 | "hono handler with multiple API routes", 65 | async ({ page, port }) => { 66 | await page.goto(`http://localhost:${port}/api/health`); 67 | await expect(page.getByText('{"health":"good"}')).toBeVisible(); 68 | 69 | await page.goto(`http://localhost:${port}/api/version`); 70 | await expect(page.getByText('{"version":"1.0.0"}')).toBeVisible(); 71 | }, 72 | { 73 | "app/routes/index.tsx": ` 74 | export default async function Index() { 75 | return
Hello World
; 76 | } 77 | `, 78 | "app/entry.server.tsx": ` 79 | import { Hono } from "hono"; 80 | import { handler } from "@orange-js/orange/hono"; 81 | import { Root } from "./root"; 82 | 83 | const app = new Hono(); 84 | 85 | // Multiple API routes 86 | app.get("/api/health", (c) => c.json({ health: "good" })); 87 | app.get("/api/version", (c) => c.json({ version: "1.0.0" })); 88 | 89 | // Use the handler to create middleware for React routing 90 | app.use("*", handler(Root)); 91 | 92 | export default app; 93 | `, 94 | } 95 | ); 96 | 97 | test.multi( 98 | "hono handler with POST route", 99 | async ({ page, port }) => { 100 | // Test POST endpoint using JavaScript fetch 101 | await page.goto(`http://localhost:${port}`); 102 | 103 | const response = await page.evaluate(async (port) => { 104 | const res = await fetch(`http://localhost:${port}/api/echo`, { 105 | method: "POST", 106 | headers: { "Content-Type": "application/json" }, 107 | body: JSON.stringify({ message: "test" }), 108 | }); 109 | return await res.json(); 110 | }, port); 111 | 112 | expect(response).toEqual({ echo: "test" }); 113 | }, 114 | { 115 | "app/routes/index.tsx": ` 116 | export default async function Index() { 117 | return
Hello World
; 118 | } 119 | `, 120 | "app/entry.server.tsx": ` 121 | import { Hono } from "hono"; 122 | import { handler } from "@orange-js/orange/hono"; 123 | import { Root } from "./root"; 124 | 125 | const app = new Hono(); 126 | 127 | // POST route for testing 128 | app.post("/api/echo", async (c) => { 129 | const body = await c.req.json(); 130 | return c.json({ echo: body.message }); 131 | }); 132 | 133 | // Use the handler to create middleware for React routing 134 | app.use("*", handler(Root)); 135 | 136 | export default app; 137 | `, 138 | } 139 | ); 140 | 141 | test.multi( 142 | "hono handler fallback to React routing", 143 | async ({ page, port }) => { 144 | await page.goto(`http://localhost:${port}/about`); 145 | await expect(page.getByText("About Page")).toBeVisible(); 146 | }, 147 | { 148 | "app/routes/index.tsx": ` 149 | export default async function Index() { 150 | return
Hello World
; 151 | } 152 | `, 153 | "app/routes/about.tsx": ` 154 | export default function About() { 155 | return
About Page
; 156 | } 157 | `, 158 | "app/entry.server.tsx": ` 159 | import { Hono } from "hono"; 160 | import { handler } from "@orange-js/orange/hono"; 161 | import { Root } from "./root"; 162 | 163 | const app = new Hono(); 164 | 165 | // API route should not interfere with React routing 166 | app.get("/api/info", (c) => c.json({ info: "available" })); 167 | 168 | // Use the handler to create middleware for React routing 169 | app.use("*", handler(Root)); 170 | 171 | export default app; 172 | `, 173 | } 174 | ); 175 | 176 | test.multi( 177 | "hono handler with variables", 178 | async ({ page, port }) => { 179 | await page.goto(`http://localhost:${port}/variables`); 180 | await expect(page.getByText("foo bar")).toBeVisible(); 181 | }, 182 | { 183 | "app/routes/variables.tsx": ` 184 | import { variables } from "@orange-js/orange/hono"; 185 | 186 | export default async function Index() { 187 | const { test } = variables(); 188 | return
{JSON.stringify(test)}
; 189 | } 190 | `, 191 | "app/entry.server.tsx": ` 192 | import { Hono } from "hono"; 193 | import { handler } from "@orange-js/orange/hono"; 194 | import { Root } from "./root"; 195 | 196 | const app = new Hono(); 197 | 198 | // API route should not interfere with React routing 199 | app.get("/api/info", (c) => c.json({ info: "available" })); 200 | 201 | app.use("*", (c, next) => { 202 | c.set("test", "foo bar"); 203 | return next(); 204 | }); 205 | 206 | // Use the handler to create middleware for React routing 207 | app.use("*", handler(Root)); 208 | 209 | export default app; 210 | `, 211 | } 212 | ); 213 | -------------------------------------------------------------------------------- /packages/core/src/server.tsx: -------------------------------------------------------------------------------- 1 | // Adapted from: 2 | // https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.rsc.tsx 3 | 4 | import * as React from "react"; 5 | import * as ReactServer from "@vitejs/plugin-rsc/rsc"; 6 | import type { ReactFormState } from "react-dom/client"; 7 | import type { ErrorInfo } from "react"; 8 | import { router } from "./router.js"; 9 | 10 | import { routes } from "virtual:orange/routes"; 11 | import { isActor } from "./actor.js"; 12 | import { CloudflareEnv } from "./index.js"; 13 | import { internalContext } from "./internal-context.js"; 14 | import { env } from "cloudflare:workers"; 15 | 16 | export interface Context { 17 | cloudflare: { 18 | env: CloudflareEnv; 19 | ctx: ExecutionContext; 20 | }; 21 | } 22 | 23 | export type AppOptions = { 24 | context?: ( 25 | env: CloudflareEnv, 26 | ) => Omit | Promise>; 27 | }; 28 | 29 | export type RscPayload = { 30 | root: React.ReactNode; 31 | returnValue?: unknown; 32 | formState?: ReactFormState; 33 | }; 34 | 35 | type Layout = (props: { children: React.ReactNode }) => React.ReactNode; 36 | 37 | export async function request() { 38 | const request = internalContext.getStore()?.request; 39 | if (!request) { 40 | throw new Error("Not within request context"); 41 | } 42 | 43 | return request; 44 | } 45 | 46 | async function handler( 47 | request: Request, 48 | Layout: Layout, 49 | onError: (error: unknown, errorInfo: ErrorInfo) => void, 50 | ): Promise { 51 | const isAction = request.method === "POST"; 52 | let returnValue: unknown | undefined; 53 | let formState: ReactFormState | undefined; 54 | let temporaryReferences: unknown | undefined; 55 | 56 | if (isAction) { 57 | // x-rsc-action header exists when action is called via `ReactClient.setServerCallback`. 58 | const actionId = request.headers.get("x-rsc-action"); 59 | if (actionId) { 60 | const contentType = request.headers.get("content-type"); 61 | const body = contentType?.startsWith("multipart/form-data") 62 | ? await request.formData() 63 | : await request.text(); 64 | temporaryReferences = ReactServer.createTemporaryReferenceSet(); 65 | const args = await ReactServer.decodeReply(body, { temporaryReferences }); 66 | const action = await ReactServer.loadServerAction(actionId); 67 | returnValue = await action.apply(null, args); 68 | } else { 69 | // otherwise server function is called via `
` 70 | // before hydration (e.g. when javascript is disabled). 71 | // aka progressive enhancement. 72 | const formData = await request.formData(); 73 | const decodedAction = await ReactServer.decodeAction(formData); 74 | const result = await decodedAction(); 75 | // @ts-ignore 76 | formState = await ReactServer.decodeFormState(result, formData); 77 | } 78 | } 79 | 80 | const route = router(routes)(request); 81 | if (!route) { 82 | return undefined; 83 | } 84 | 85 | const match = route.pattern.exec(request.url); 86 | const params = match?.pathname.groups ?? {}; 87 | 88 | return await internalContext.run({ request, params }, async () => { 89 | const { default: maybeComponent } = route.module; 90 | let Component: (props: { 91 | request: Request; 92 | params: Record; 93 | }) => React.ReactNode | Promise; 94 | 95 | if (isActor(maybeComponent)) { 96 | // @ts-ignore 97 | const name = maybeComponent.nameFromRequest(request); 98 | Component = () => ( 99 | // @ts-ignore 100 | 101 | ); 102 | } else { 103 | Component = maybeComponent; 104 | } 105 | 106 | return await rscResponse({ 107 | root: ( 108 | 109 | 110 | 111 | ), 112 | request, 113 | returnValue, 114 | formState, 115 | onError, 116 | }); 117 | }); 118 | } 119 | 120 | type RscResponseOptions = { 121 | root: React.ReactNode; 122 | request: Request; 123 | returnValue?: unknown; 124 | formState?: ReactFormState; 125 | onError: (error: unknown, errorInfo: ErrorInfo) => void; 126 | }; 127 | 128 | async function rscResponse({ 129 | root, 130 | request, 131 | returnValue, 132 | formState, 133 | onError, 134 | }: RscResponseOptions) { 135 | const rscStream = ReactServer.renderToReadableStream( 136 | { 137 | // in this example, we always render the same `` 138 | root, 139 | returnValue, 140 | formState, 141 | }, 142 | { 143 | onError, 144 | }, 145 | ); 146 | 147 | const url = new URL(request.url); 148 | const isRscRequest = 149 | (!request.headers.get("accept")?.includes("text/html") && 150 | !url.searchParams.has("__html")) || 151 | url.searchParams.has("__rsc"); 152 | 153 | if (isRscRequest) { 154 | return new Response(rscStream, { 155 | headers: { 156 | "content-type": "text/x-component;charset=utf-8", 157 | vary: "accept", 158 | }, 159 | }); 160 | } 161 | 162 | const { renderHTML } = await import.meta.viteRsc.loadModule< 163 | typeof import("./ssr.js") 164 | >("ssr", "index"); 165 | 166 | const htmlStream = await renderHTML(rscStream, { 167 | formState, 168 | // allow quick simulation of javscript disabled browser 169 | debugNojs: url.searchParams.has("__nojs"), 170 | onError(error: unknown, errorInfo: ErrorInfo) { 171 | console.error("Error during RSC serialization", error, errorInfo); 172 | onError(error, errorInfo); 173 | }, 174 | }); 175 | 176 | return new Response(htmlStream, { 177 | headers: { 178 | "Content-type": "text/html", 179 | vary: "accept", 180 | }, 181 | }); 182 | } 183 | 184 | import.meta.hot?.accept(); 185 | 186 | const wsPattern = new URLPattern({ 187 | pathname: "/:actor/:id", 188 | }); 189 | 190 | export function app(Layout: Layout, options?: AppOptions) { 191 | return { 192 | async fetch(request: Request) { 193 | let reactError: unknown | undefined; 194 | let reactErrorInfo: ErrorInfo | undefined; 195 | 196 | try { 197 | if (request.headers.get("Upgrade") === "websocket") { 198 | const match = wsPattern.exec(request.url); 199 | if (!match) { 200 | return new Response("Not found", { status: 404 }); 201 | } 202 | 203 | const { actor, id } = match.pathname.groups; 204 | const ns = (env as any)[actor] as DurableObjectNamespace; 205 | const stubId = ns.idFromName(id); 206 | const stub = ns.get(stubId); 207 | await stub.setIdentifier(id); 208 | // @ts-ignore 209 | return await stub!.fetch(request); 210 | } 211 | 212 | return ( 213 | (await handler(request, Layout, (err, errorInfo) => { 214 | reactError = err; 215 | reactErrorInfo = errorInfo; 216 | })) ?? new Response("Not found", { status: 404 }) 217 | ); 218 | } catch (error) { 219 | const err = reactError ?? error; 220 | 221 | const { renderErrorBoundaryResponse } = 222 | await import.meta.viteRsc.loadModule( 223 | "ssr", 224 | "index", 225 | ); 226 | 227 | const stream = await renderErrorBoundaryResponse( 228 | err instanceof Error 229 | ? { 230 | message: err.message, 231 | stack: err.stack, 232 | } 233 | : { 234 | message: String(err), 235 | }, 236 | ); 237 | return new Response(stream, { 238 | headers: { 239 | "Content-type": "text/html", 240 | vary: "accept", 241 | }, 242 | }); 243 | } 244 | }, 245 | }; 246 | } 247 | -------------------------------------------------------------------------------- /e2e/fixture/index.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import getPort from "get-port"; 3 | import * as path from "node:path"; 4 | import * as fs from "node:fs/promises"; 5 | import { test as base, Browser, Page } from "@playwright/test"; 6 | import { spawn } from "node:child_process"; 7 | import { Unstable_Config } from "wrangler"; 8 | import { stripVTControlCharacters } from "node:util"; 9 | import { DisposeScope } from "./util"; 10 | 11 | export type Files = Record; 12 | 13 | export async function create(files: Files, name: string) { 14 | const root = path.join(__dirname, "../../"); 15 | const fixtureDir = path.join( 16 | root, 17 | ".test-fixtures", 18 | name.replaceAll(" ", "-") 19 | ); 20 | const templateDir = path.join(path.dirname(__dirname), "templates", "basic"); 21 | 22 | await fs.rm(fixtureDir, { recursive: true, force: true }).catch(() => {}); 23 | 24 | await fs.cp(templateDir, fixtureDir, { 25 | recursive: true, 26 | }); 27 | 28 | const writes = Object.entries(files).map(([filePath, contents]) => 29 | fs.writeFile(path.join(fixtureDir, filePath), dedent(contents)) 30 | ); 31 | 32 | await Promise.all(writes); 33 | 34 | return fixtureDir; 35 | } 36 | 37 | type RunCmdOpts = { 38 | cmd: string; 39 | args: string[]; 40 | cwd: string; 41 | waitForText?: string; 42 | waitForExit?: boolean; 43 | }; 44 | 45 | export async function runCmd({ 46 | cmd, 47 | args, 48 | cwd, 49 | waitForText, 50 | waitForExit, 51 | }: RunCmdOpts) { 52 | const commandProcess = spawn(cmd, args, { 53 | cwd, 54 | stdio: "pipe", 55 | env: { 56 | ...process.env, 57 | NO_COLOR: "true", 58 | }, 59 | }); 60 | 61 | let output: { type: "stdout" | "stderr"; text: string }[] = []; 62 | 63 | commandProcess.stdout.on("data", (data) => { 64 | const text = new TextDecoder().decode(data); 65 | output.push({ type: "stdout", text }); 66 | }); 67 | commandProcess.stderr.on("data", (data) => { 68 | const text = new TextDecoder().decode(data); 69 | output.push({ type: "stderr", text }); 70 | }); 71 | 72 | if (waitForText) { 73 | let resolve: (_: unknown) => void; 74 | let reject: (_: unknown) => void; 75 | const promise = new Promise((res, rej) => { 76 | resolve = res; 77 | reject = rej; 78 | }); 79 | 80 | commandProcess.stdout.on("data", (data) => { 81 | const text = new TextDecoder().decode(data); 82 | if (text.includes(waitForText)) { 83 | resolve(undefined); 84 | } 85 | }); 86 | 87 | commandProcess.on("close", (code, signal) => { 88 | if (code !== 0) { 89 | for (const { type, text } of output) { 90 | if (type === "stderr") { 91 | process.stderr.write(text); 92 | } else { 93 | process.stdout.write(text); 94 | } 95 | } 96 | reject( 97 | new Error(`Command closed with code ${code} and signal ${signal}`) 98 | ); 99 | } 100 | }); 101 | 102 | commandProcess.on("error", (err) => { 103 | reject(err); 104 | }); 105 | 106 | await promise; 107 | } 108 | 109 | if (waitForExit) { 110 | let resolve: (_: unknown) => void; 111 | const promise = new Promise((res) => { 112 | resolve = res; 113 | }); 114 | 115 | commandProcess.on("close", () => { 116 | resolve(undefined); 117 | }); 118 | 119 | await promise; 120 | } 121 | 122 | return { 123 | kill: () => commandProcess.kill(), 124 | output: () => output.reduce((acc, { text }) => acc + text, ""), 125 | }; 126 | } 127 | 128 | export type CreateServer = ( 129 | files?: Files 130 | ) => Promise<{ port: number } & T>; 131 | 132 | const orangeTest = base.extend<{ 133 | dev: CreateServer<{ 134 | addFile: (filePath: string, contents: string) => Promise; 135 | }>; 136 | worker: CreateServer; 137 | }>({ 138 | dev: async ({}, use, testInfo) => { 139 | const tasks = new DisposeScope(); 140 | 141 | await use(async (files) => { 142 | const fixtureDir = await create(files ?? {}, testInfo.title); 143 | tasks.register(() => fs.rm(fixtureDir, { force: true, recursive: true })); 144 | 145 | const port = await getPort(); 146 | const devServer = await runCmd({ 147 | cmd: "node_modules/.bin/vite", 148 | args: ["dev", "--port", port.toString()], 149 | cwd: fixtureDir, 150 | waitForText: `localhost:${port}`, 151 | }); 152 | 153 | tasks.register(devServer.kill); 154 | tasks.register(() => 155 | testInfo.attach("Vite dev server", { body: devServer.output() }) 156 | ); 157 | 158 | return { 159 | port, 160 | addFile: async (filePath: string, contents: string) => { 161 | fs.writeFile(path.join(fixtureDir, filePath), dedent(contents)); 162 | }, 163 | }; 164 | }); 165 | 166 | // TODO: prettier doesnt support using 167 | await tasks[Symbol.asyncDispose](); 168 | }, 169 | worker: async ({}, use, testInfo) => { 170 | const tasks = new DisposeScope(); 171 | 172 | await use(async (files) => { 173 | const fixtureDir = await create(files ?? {}, testInfo.title); 174 | tasks.register(() => fs.rm(fixtureDir, { force: true, recursive: true })); 175 | 176 | const build = await runCmd({ 177 | cmd: "node_modules/.bin/vite", 178 | args: ["build"], 179 | cwd: fixtureDir, 180 | waitForExit: true, 181 | }); 182 | build.kill(); 183 | tasks.register(() => 184 | testInfo.attach("Vite build", { body: build.output() }) 185 | ); 186 | 187 | const port = await getPort(); 188 | const inspectorPort = await getPort(); 189 | const server = await runCmd({ 190 | cmd: "node_modules/.bin/wrangler", 191 | args: [ 192 | "dev", 193 | "--port", 194 | port.toString(), 195 | "--inspector-port", 196 | inspectorPort.toString(), 197 | ], 198 | cwd: fixtureDir, 199 | waitForText: `localhost:${port}`, 200 | }); 201 | 202 | tasks.register(server.kill); 203 | tasks.register(() => 204 | testInfo.attach("Wrangler dev server", { 205 | body: stripVTControlCharacters(server.output()), 206 | }) 207 | ); 208 | return { port }; 209 | }); 210 | 211 | // TODO: prettier doesnt support using 212 | await tasks[Symbol.asyncDispose](); 213 | }, 214 | }); 215 | 216 | export const test = orangeTest as typeof orangeTest & { 217 | multi: typeof multitest; 218 | dev: typeof devtest; 219 | }; 220 | 221 | test.multi = multitest; 222 | test.dev = devtest; 223 | 224 | function devtest( 225 | title: string, 226 | fn: (opts: { 227 | page: Page; 228 | port: number; 229 | addFile: (filePath: string, contents: string) => Promise; 230 | browser: Browser; 231 | }) => Promise, 232 | files: Files = {} 233 | ) { 234 | test(`${title} dev`, async ({ page, dev, browser }) => { 235 | const { port, addFile } = await dev(files); 236 | await fn({ page, port, addFile, browser }); 237 | }); 238 | } 239 | 240 | function multitest( 241 | title: string, 242 | fn: (opts: { 243 | page: Page; 244 | port: number; 245 | browser: Browser; 246 | isDev: boolean; 247 | }) => Promise, 248 | files: Files = {} 249 | ) { 250 | test(`${title} dev`, async ({ page, dev, browser }) => { 251 | const { port } = await dev(files); 252 | await fn({ page, port, browser, isDev: true }); 253 | }); 254 | 255 | test(`${title} worker`, async ({ page, worker, browser }) => { 256 | const { port } = await worker(files); 257 | await fn({ page, port, browser, isDev: false }); 258 | }); 259 | } 260 | 261 | export * from "@playwright/test"; 262 | 263 | const baseConfig = { 264 | name: "basic", 265 | main: "./app/entry.server.tsx", 266 | compatibility_date: "2025-05-11", 267 | compatibility_flags: ["nodejs_compat"], 268 | assets: { 269 | directory: "./dist/client", 270 | }, 271 | observability: { 272 | enabled: true, 273 | }, 274 | }; 275 | 276 | export function wranglerJson(config: Partial) { 277 | return JSON.stringify( 278 | { 279 | ...baseConfig, 280 | ...config, 281 | }, 282 | null, 283 | 2 284 | ); 285 | } 286 | -------------------------------------------------------------------------------- /packages/cli/src/commands/provision/postgres.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import c from "chalk"; 3 | import { Cloudflare } from "cloudflare"; 4 | import { 5 | unstable_readConfig as readWranglerConfig, 6 | experimental_patchConfig as patchConfig, 7 | } from "wrangler"; 8 | import { writeFileSync } from "node:fs"; 9 | import { join } from "node:path"; 10 | import { match } from "ts-pattern"; 11 | import { detect } from "detect-package-manager"; 12 | 13 | import { 14 | loader, 15 | password, 16 | text, 17 | assertNotCancelled, 18 | step, 19 | confirm, 20 | select, 21 | orange, 22 | log, 23 | warn, 24 | } from "../../prompts.js"; 25 | import { exec } from "../../exec.js"; 26 | import { generateWranglerTypes } from "../types.js"; 27 | import { 28 | camelCase, 29 | installedNodeModules, 30 | isDrizzleInstalled, 31 | } from "./index.js"; 32 | 33 | export async function provisionPostgres(client: Cloudflare, accountId: string) { 34 | const config = readWranglerConfig({}); 35 | const hyperdriveDatbases = await loader( 36 | client.hyperdrive.configs.list({ 37 | account_id: accountId, 38 | }), 39 | { 40 | start: "Fetching existing databases...", 41 | success: (value) => `Found ${value.result.length} existing databases`, 42 | error: "Failed to fetch existing databases", 43 | }, 44 | ); 45 | 46 | const existingDatabases = hyperdriveDatbases.result.map((db) => db.name); 47 | 48 | const databaseName = await text({ 49 | message: "Enter the name of the database", 50 | placeholder: "my-database", 51 | validate(value) { 52 | if (value.length === 0) { 53 | return "Database name cannot be empty"; 54 | } 55 | 56 | if (existingDatabases.includes(value)) { 57 | return `Database ${value} already exists`; 58 | } 59 | }, 60 | }); 61 | assertNotCancelled(databaseName); 62 | 63 | const connectionString = await password({ 64 | message: "Enter the connection URL for the database", 65 | placeholder: "postgres://user:password@host:6542/database", 66 | mask: "*", 67 | validate(value) { 68 | try { 69 | const url = new URL(value); 70 | if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") { 71 | return "Invalid connection URL"; 72 | } 73 | } catch (error) { 74 | return `${error}`; 75 | } 76 | }, 77 | }); 78 | assertNotCancelled(connectionString); 79 | 80 | const localConnectionString = await password({ 81 | message: 82 | "Optionally, enter the connection URL use for local development.\nLeave blank to use the remote database.", 83 | placeholder: "postgres://user:password@host:6542/database", 84 | mask: "*", 85 | validate(value) { 86 | if (value.length === 0) { 87 | return; 88 | } 89 | 90 | try { 91 | const url = new URL(value); 92 | if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") { 93 | return "Invalid connection URL"; 94 | } 95 | } catch (error) { 96 | return "Invalid connection URL"; 97 | } 98 | }, 99 | }); 100 | assertNotCancelled(localConnectionString); 101 | 102 | const caching = await confirm({ 103 | message: "Do you want to enable caching for this database?", 104 | }); 105 | assertNotCancelled(caching); 106 | 107 | const url = new URL(connectionString); 108 | const database = url.pathname.slice(1); 109 | const username = url.username; 110 | const dbPassword = url.password; 111 | const host = url.hostname; 112 | const port = url.port; 113 | 114 | const result = await loader( 115 | client.hyperdrive.configs.create({ 116 | account_id: accountId, 117 | name: databaseName, 118 | origin: { 119 | host, 120 | port: port ? parseInt(port) : 5432, 121 | user: username, 122 | password: dbPassword, 123 | database, 124 | scheme: "postgres", 125 | }, 126 | caching: { 127 | disabled: !caching, 128 | }, 129 | }), 130 | { 131 | start: "Provisioning database...", 132 | success: (value) => 133 | `Database ${databaseName} provisioned as ${orange(value.id)}`, 134 | error: "Failed to provision database", 135 | }, 136 | ); 137 | 138 | patchConfig( 139 | config.configPath!, 140 | { 141 | hyperdrive: [ 142 | { 143 | binding: camelCase(databaseName), 144 | id: result.id, 145 | localConnectionString: localConnectionString || undefined, 146 | }, 147 | ], 148 | }, 149 | true, 150 | ); 151 | 152 | const createClient = await confirm({ 153 | message: 154 | "Do you want to create a Postgres client accessible in your loaders?", 155 | }); 156 | assertNotCancelled(createClient); 157 | 158 | if (createClient) { 159 | const client = await determineClient(); 160 | const template = match(client) 161 | .with("pg", () => 162 | connectFileTemplate( 163 | databaseName, 164 | 'import { Client } from "pg";', 165 | `new Client(env.${camelCase(databaseName)}.connectionString)`, 166 | ), 167 | ) 168 | .with("postgres", () => 169 | connectFileTemplate( 170 | databaseName, 171 | 'import postgres from "postgres";', 172 | `postgres(env.${camelCase(databaseName)}.connectionString)`, 173 | ), 174 | ) 175 | .with("drizzle-orm-pg", () => 176 | connectFileTemplate( 177 | databaseName, 178 | [ 179 | 'import { drizzle } from "drizzle-orm/node-postgres";', 180 | 'import * as schema from "./schema.server";', 181 | ], 182 | `drizzle(env.${camelCase(databaseName)}.connectionString, { schema })`, 183 | ), 184 | ) 185 | .with("drizzle-orm-postgres", () => 186 | connectFileTemplate( 187 | databaseName, 188 | [ 189 | 'import { drizzle } from "drizzle-orm/postgres-js";', 190 | 'import * as schema from "./schema.server";', 191 | ], 192 | `drizzle(env.${camelCase(databaseName)}.connectionString, { schema })`, 193 | ), 194 | ) 195 | .exhaustive(); 196 | 197 | if (client.includes("drizzle")) { 198 | log(c.dim("You'll need to create a schema file in your app directory.")); 199 | } 200 | 201 | writeFileSync(join(process.cwd(), "app/database.server.ts"), template); 202 | } 203 | 204 | await loader(generateWranglerTypes(), { 205 | start: "Generating Wrangler types...", 206 | success: () => "Generated Wrangler types", 207 | error: "Failed to generate Wrangler types", 208 | }); 209 | 210 | step( 211 | `Created connection to postgres accessible via \`${c.dim( 212 | `env.${camelCase(databaseName)}`, 213 | )}\` and \`${c.dim(`context.${camelCase(databaseName)}`)}\``, 214 | ); 215 | 216 | warn( 217 | dedent` 218 | Add \`${orange("connect")}\` from \`${orange( 219 | "app/database.context.ts", 220 | )}\` to your entrypoint defined in \`${orange("app/entry.server.tsx")}\` 221 | See more at ${orange("https://orange-js.dev/docs/context")} 222 | `.trim(), 223 | ); 224 | } 225 | 226 | const postgresClients = ["pg", "postgres"] as const; 227 | 228 | function installedPostgresClient() { 229 | const installed = installedNodeModules(); 230 | return postgresClients.find((client) => installed.includes(client)); 231 | } 232 | 233 | async function determineClient(): Promise< 234 | "pg" | "postgres" | "drizzle-orm-pg" | "drizzle-orm-postgres" 235 | > { 236 | const installed = installedPostgresClient(); 237 | if (installed !== undefined) { 238 | if (isDrizzleInstalled()) { 239 | return installed === "pg" ? "drizzle-orm-pg" : "drizzle-orm-postgres"; 240 | } 241 | return installed; 242 | } 243 | 244 | const client = await select({ 245 | message: "Select a Postgres library to install", 246 | options: [ 247 | { title: "pg", value: "pg" }, 248 | { title: "postgres", value: "postgres" }, 249 | { title: "Drizzle via pg", value: "drizzle-orm-pg" }, 250 | { title: "Drizzle via postgres", value: "drizzle-orm-postgres" }, 251 | ], 252 | }); 253 | assertNotCancelled(client); 254 | 255 | const packages = match(client) 256 | .with("pg", () => ["pg"]) 257 | .with("postgres", () => ["postgres"]) 258 | .with("drizzle-orm-pg", () => ["drizzle-orm", "pg"]) 259 | .with("drizzle-orm-postgres", () => ["drizzle-orm", "postgres"]) 260 | .run(); 261 | 262 | await loader(installPackages(packages), { 263 | start: `Installing ${packages.join(", ")}...`, 264 | success: () => "Installed", 265 | error: "Failed to install Postgres library", 266 | }); 267 | 268 | return client as 269 | | "pg" 270 | | "postgres" 271 | | "drizzle-orm-pg" 272 | | "drizzle-orm-postgres"; 273 | } 274 | 275 | function connectFileTemplate( 276 | databaseName: string, 277 | imports: string | string[], 278 | databaseExpr: string, 279 | ) { 280 | return dedent` 281 | import { env } from "cloudflare:workers"; 282 | ${Array.isArray(imports) ? imports.join("\n") : imports} 283 | 284 | declare module "@orange-js/orange" { 285 | // Add the database to the context. 286 | interface Context extends ContextFrom {} 287 | } 288 | 289 | export async function connect() { 290 | return { 291 | ${camelCase(databaseName)}: ${databaseExpr}, 292 | }; 293 | } 294 | `; 295 | } 296 | 297 | async function installPackages(packages: string[]) { 298 | const pm = await detect(); 299 | const command = match(pm) 300 | .with("npm", () => ["install", ...packages]) 301 | .with("yarn", () => ["add", ...packages]) 302 | .with("pnpm", () => ["add", ...packages]) 303 | .with("bun", () => ["add", ...packages]) 304 | .exhaustive(); 305 | 306 | await exec(pm, command); 307 | } 308 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2024", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 16 | "jsx": "react", 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "NodeNext", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ 40 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 41 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 42 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 43 | // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ 44 | // "resolveJsonModule": true, /* Enable importing .json files. */ 45 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 46 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 47 | 48 | /* JavaScript Support */ 49 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 50 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 51 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 52 | 53 | /* Emit */ 54 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 55 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 56 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 57 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "noEmit": true, /* Disable emitting files from a compilation. */ 60 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 61 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 62 | // "removeComments": true, /* Disable emitting comments. */ 63 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 64 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 65 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 66 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 67 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 68 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 69 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 70 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 71 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 72 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 73 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 74 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ 80 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 81 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 82 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 83 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 84 | 85 | /* Type Checking */ 86 | "strict": true, /* Enable all strict type-checking options. */ 87 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 88 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 89 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 90 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 91 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 92 | // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ 93 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 94 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 95 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 96 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 97 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 98 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 99 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 100 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 101 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 102 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 103 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 104 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 105 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 106 | 107 | /* Completeness */ 108 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 109 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /packages/cli/src/prompts.ts: -------------------------------------------------------------------------------- 1 | // Adapted from: https://github.com/bombshell-dev/clack/blob/main/packages/prompts/src/index.ts 2 | 3 | import { 4 | block, 5 | PasswordPrompt, 6 | SelectPrompt, 7 | State, 8 | TextPrompt, 9 | isCancel, 10 | ConfirmPrompt, 11 | MultiSelectPrompt, 12 | } from "@clack/core"; 13 | import c from "chalk"; 14 | import util from "node:util"; 15 | import isUnicodeSupported from "is-unicode-supported"; 16 | import type { Readable, Writable } from "node:stream"; 17 | import { WriteStream } from "node:tty"; 18 | import { match } from "ts-pattern"; 19 | import { cursor, erase } from "sisteransi"; 20 | import { Option } from "@clack/prompts"; 21 | 22 | export const orange = (text: string) => c.rgb(249, 115, 22)(text); 23 | 24 | const unicode = isUnicodeSupported(); 25 | const s = (c: string, fallback: string) => (unicode ? c : fallback); 26 | 27 | const S_STEP_ACTIVE = s("◆", "*"); 28 | const S_STEP_CANCEL = s("■", "x"); 29 | const S_STEP_ERROR = s("▲", "x"); 30 | const S_STEP_SUBMIT = s("◇", "o"); 31 | 32 | const S_BAR_START = s("┌", "T"); 33 | const S_BAR = s("│", "|"); 34 | const S_BAR_END = s("└", "—"); 35 | 36 | const S_RADIO_ACTIVE = s("●", ">"); 37 | const S_RADIO_INACTIVE = s("○", " "); 38 | const S_CHECKBOX_ACTIVE = s("◻", "[•]"); 39 | const S_CHECKBOX_SELECTED = s("◼", "[+]"); 40 | const S_CHECKBOX_INACTIVE = s("◻", "[ ]"); 41 | const S_PASSWORD_MASK = s("▪", "•"); 42 | 43 | const S_BAR_H = s("─", "-"); 44 | const S_CORNER_TOP_RIGHT = s("╮", "+"); 45 | const S_CONNECT_LEFT = s("├", "+"); 46 | const S_CORNER_BOTTOM_RIGHT = s("╯", "+"); 47 | 48 | const S_INFO = s("●", "•"); 49 | const S_SUCCESS = s("◆", "*"); 50 | const S_WARN = s("▲", "!"); 51 | const S_ERROR = s("■", "x"); 52 | 53 | const symbol = (state: State) => { 54 | switch (state) { 55 | case "initial": 56 | case "active": 57 | return c.rgb(249, 115, 22)(S_INFO); 58 | case "cancel": 59 | return c.red(S_STEP_CANCEL); 60 | case "error": 61 | return c.yellow(S_STEP_ERROR); 62 | case "submit": 63 | return c.green(S_STEP_SUBMIT); 64 | } 65 | }; 66 | 67 | const colorByState = (state: State, text: string) => { 68 | switch (state) { 69 | case "initial": 70 | case "active": 71 | return c.rgb(249, 115, 22)(text); 72 | case "cancel": 73 | return c.red(text); 74 | case "error": 75 | return c.yellow(text); 76 | case "submit": 77 | return c.green(text); 78 | } 79 | }; 80 | 81 | export async function select(options: { 82 | message: string; 83 | options: { title: string; value: T }[]; 84 | }): Promise { 85 | const highlight = ( 86 | text: string, 87 | state: "inactive" | "active" | "selected" | "cancelled", 88 | ) => 89 | match(state) 90 | .with("inactive", () => `${c.dim(S_RADIO_INACTIVE)} ${c.dim(text)}`) 91 | .with("active", () => `${orange(S_STEP_ACTIVE)} ${c.dim(text)}`) 92 | .with("selected", () => c.dim(text)) 93 | .with("cancelled", () => c.strikethrough(c.dim(text))) 94 | .exhaustive(); 95 | 96 | const prompt = new SelectPrompt({ 97 | options: options.options, 98 | render() { 99 | const title = `${c.gray(S_BAR)}\n${symbol(this.state)} ${ 100 | options.message 101 | }\n`; 102 | 103 | return match(this.state) 104 | .with( 105 | "cancel", 106 | () => 107 | `${title}${c.gray(S_BAR)} ${highlight( 108 | this.options[this.cursor].title, 109 | "cancelled", 110 | )}\n${c.gray(S_BAR)}`, 111 | ) 112 | .with( 113 | "submit", 114 | () => 115 | `${title}${c.gray(S_BAR)} ${highlight( 116 | this.options[this.cursor].title, 117 | "selected", 118 | )}`, 119 | ) 120 | .otherwise( 121 | () => 122 | `${title}${orange(S_BAR)} ${limitOptions({ 123 | cursor: this.cursor, 124 | options: this.options, 125 | maxItems: 10, 126 | style: (item, active) => 127 | highlight(item.title, active ? "active" : "inactive"), 128 | }).join(`\n${orange(S_BAR)} `)}\n${orange(S_BAR_END)}\n`, 129 | ); 130 | }, 131 | }); 132 | 133 | return (await prompt.prompt()) as T | symbol; 134 | } 135 | 136 | export interface TextOptions extends CommonOptions { 137 | message: string; 138 | placeholder?: string; 139 | defaultValue?: string; 140 | initialValue?: string; 141 | validate?: (value: string) => string | Error | undefined; 142 | } 143 | 144 | export const text = (opts: TextOptions) => { 145 | return new TextPrompt({ 146 | validate: opts.validate, 147 | placeholder: opts.placeholder, 148 | defaultValue: opts.defaultValue, 149 | initialValue: opts.initialValue, 150 | output: opts.output, 151 | input: opts.input, 152 | render() { 153 | const title = `${c.gray(S_BAR)}\n${symbol(this.state)} ${ 154 | opts.message 155 | }\n`; 156 | const placeholder = opts.placeholder 157 | ? c.inverse(opts.placeholder[0]) + c.dim(opts.placeholder.slice(1)) 158 | : c.inverse(c.hidden("_")); 159 | const value = !this.value ? placeholder : this.valueWithCursor; 160 | 161 | switch (this.state) { 162 | case "error": 163 | return `${title.trim()}\n${c.yellow(S_BAR)} ${value}\n${c.yellow( 164 | S_BAR_END, 165 | )} ${c.yellow(this.error)}\n`; 166 | case "submit": 167 | return `${title}${c.gray(S_BAR)} ${c.dim( 168 | this.value || opts.placeholder, 169 | )}`; 170 | case "cancel": 171 | return `${title}${c.gray(S_BAR)} ${c.strikethrough( 172 | c.dim(this.value ?? ""), 173 | )}${this.value?.trim() ? `\n${c.gray(S_BAR)}` : ""}`; 174 | default: 175 | return `${title}${orange(S_BAR)} ${value}\n${orange(S_BAR_END)}\n`; 176 | } 177 | }, 178 | }).prompt() as Promise; 179 | }; 180 | 181 | export interface PasswordOptions extends CommonOptions { 182 | message: string; 183 | placeholder?: string; 184 | mask?: string; 185 | validate?: (value: string) => string | Error | undefined; 186 | } 187 | export const password = (opts: PasswordOptions) => { 188 | return new PasswordPrompt({ 189 | validate: opts.validate, 190 | mask: opts.mask ?? S_PASSWORD_MASK, 191 | input: opts.input, 192 | output: opts.output, 193 | render() { 194 | const isDefault = 195 | this.state !== "error" && 196 | this.state !== "submit" && 197 | this.state !== "cancel"; 198 | const title = 199 | opts.message 200 | .split("\n") 201 | .map( 202 | (line, index) => 203 | `${!isDefault || index === 0 ? c.gray(S_BAR) : orange(S_BAR)} ${ 204 | index === 0 ? `\n${symbol(this.state)} ` : "" 205 | } ${line}`, 206 | ) 207 | .join("\n") + "\n"; 208 | const value = this.valueWithCursor; 209 | const masked = this.masked; 210 | 211 | switch (this.state) { 212 | case "error": 213 | return `${title.trim()}\n${c.yellow(S_BAR)} ${masked}\n${c.yellow( 214 | S_BAR_END, 215 | )} ${c.yellow(this.error)}\n`; 216 | case "submit": 217 | return `${title}${c.gray(S_BAR)} ${c.dim(masked)}`; 218 | case "cancel": 219 | return `${title}${c.gray(S_BAR)} ${c.strikethrough( 220 | c.dim(masked ?? ""), 221 | )}${masked ? `\n${c.gray(S_BAR)}` : ""}`; 222 | default: 223 | return `${title}${orange(S_BAR)} ${ 224 | this.value.length === 0 ? c.dim(opts.placeholder) : value 225 | }\n${orange(S_BAR_END)}\n`; 226 | } 227 | }, 228 | }).prompt() as Promise; 229 | }; 230 | 231 | export interface CommonOptions { 232 | input?: Readable; 233 | output?: Writable; 234 | } 235 | 236 | interface LimitOptionsParams extends CommonOptions { 237 | options: TOption[]; 238 | maxItems: number | undefined; 239 | cursor: number; 240 | style: (option: TOption, active: boolean) => string; 241 | } 242 | 243 | const limitOptions = ( 244 | params: LimitOptionsParams, 245 | ): string[] => { 246 | const { cursor, options, style } = params; 247 | const output: Writable = params.output ?? process.stdout; 248 | const rows = 249 | output instanceof WriteStream && output.rows !== undefined 250 | ? output.rows 251 | : 10; 252 | 253 | const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY; 254 | const outputMaxItems = Math.max(rows - 4, 0); 255 | // We clamp to minimum 5 because anything less doesn't make sense UX wise 256 | const maxItems = Math.min(outputMaxItems, Math.max(paramMaxItems, 5)); 257 | let slidingWindowLocation = 0; 258 | 259 | if (cursor >= slidingWindowLocation + maxItems - 3) { 260 | slidingWindowLocation = Math.max( 261 | Math.min(cursor - maxItems + 3, options.length - maxItems), 262 | 0, 263 | ); 264 | } else if (cursor < slidingWindowLocation + 2) { 265 | slidingWindowLocation = Math.max(cursor - 2, 0); 266 | } 267 | 268 | const shouldRenderTopEllipsis = 269 | maxItems < options.length && slidingWindowLocation > 0; 270 | const shouldRenderBottomEllipsis = 271 | maxItems < options.length && 272 | slidingWindowLocation + maxItems < options.length; 273 | 274 | return options 275 | .slice(slidingWindowLocation, slidingWindowLocation + maxItems) 276 | .map((option, i, arr) => { 277 | const isTopLimit = i === 0 && shouldRenderTopEllipsis; 278 | const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis; 279 | return isTopLimit || isBottomLimit 280 | ? c.dim("...") 281 | : style(option, i + slidingWindowLocation === cursor); 282 | }); 283 | }; 284 | 285 | export interface ConfirmOptions extends CommonOptions { 286 | message: string; 287 | active?: string; 288 | inactive?: string; 289 | initialValue?: boolean; 290 | } 291 | export const confirm = (opts: ConfirmOptions) => { 292 | const active = opts.active ?? "Yes"; 293 | const inactive = opts.inactive ?? "No"; 294 | return new ConfirmPrompt({ 295 | active, 296 | inactive, 297 | input: opts.input, 298 | output: opts.output, 299 | initialValue: opts.initialValue ?? true, 300 | render() { 301 | const title = `${c.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; 302 | const value = this.value ? active : inactive; 303 | 304 | switch (this.state) { 305 | case "submit": 306 | return `${title}${c.gray(S_BAR)} ${c.dim(value)}`; 307 | case "cancel": 308 | return `${title}${c.gray(S_BAR)} ${c.strikethrough( 309 | c.dim(value), 310 | )}\n${c.gray(S_BAR)}`; 311 | default: { 312 | return `${title}${orange(S_BAR)} ${ 313 | this.value 314 | ? `${c.green(S_RADIO_ACTIVE)} ${active}` 315 | : `${c.dim(S_RADIO_INACTIVE)} ${c.dim(active)}` 316 | } ${c.dim("/")} ${ 317 | !this.value 318 | ? `${c.green(S_RADIO_ACTIVE)} ${inactive}` 319 | : `${c.dim(S_RADIO_INACTIVE)} ${c.dim(inactive)}` 320 | }\n${orange(S_BAR_END)}\n`; 321 | } 322 | } 323 | }, 324 | }).prompt() as Promise; 325 | }; 326 | 327 | export interface SpinnerOptions extends CommonOptions { 328 | indicator?: "dots" | "timer"; 329 | onCancel?: () => void; 330 | } 331 | 332 | export interface SpinnerResult { 333 | start(msg?: string): void; 334 | stop(msg?: string, code?: number): void; 335 | message(msg?: string): void; 336 | readonly isCancelled: boolean; 337 | } 338 | 339 | export function spinner({ 340 | indicator = "dots", 341 | onCancel, 342 | output = process.stdout, 343 | }: SpinnerOptions = {}): SpinnerResult { 344 | const frames = unicode ? ["◒", "◐", "◓", "◑"] : ["•", "o", "O", "0"]; 345 | const delay = unicode ? 80 : 120; 346 | const isCI = process.env.CI === "true"; 347 | 348 | let unblock: () => void; 349 | let loop: NodeJS.Timeout; 350 | let isSpinnerActive = false; 351 | let isCancelled = false; 352 | let _message = ""; 353 | let _prevMessage: string | undefined = undefined; 354 | let _origin: number = performance.now(); 355 | 356 | const handleExit = (code: number) => { 357 | const msg = code > 1 ? "Something went wrong" : "Canceled"; 358 | isCancelled = code === 1; 359 | if (isSpinnerActive) { 360 | stop(msg, code); 361 | if (isCancelled && typeof onCancel === "function") { 362 | onCancel(); 363 | } 364 | } 365 | }; 366 | 367 | const errorEventHandler = () => handleExit(2); 368 | const signalEventHandler = () => handleExit(1); 369 | 370 | const registerHooks = () => { 371 | // Reference: https://nodejs.org/api/process.html#event-uncaughtexception 372 | process.on("uncaughtExceptionMonitor", errorEventHandler); 373 | // Reference: https://nodejs.org/api/process.html#event-unhandledrejection 374 | process.on("unhandledRejection", errorEventHandler); 375 | // Reference Signal Events: https://nodejs.org/api/process.html#signal-events 376 | process.on("SIGINT", signalEventHandler); 377 | process.on("SIGTERM", signalEventHandler); 378 | process.on("exit", handleExit); 379 | }; 380 | 381 | const clearHooks = () => { 382 | process.removeListener("uncaughtExceptionMonitor", errorEventHandler); 383 | process.removeListener("unhandledRejection", errorEventHandler); 384 | process.removeListener("SIGINT", signalEventHandler); 385 | process.removeListener("SIGTERM", signalEventHandler); 386 | process.removeListener("exit", handleExit); 387 | }; 388 | 389 | const clearPrevMessage = () => { 390 | if (_prevMessage === undefined) return; 391 | if (isCI) output.write("\n"); 392 | const prevLines = _prevMessage.split("\n"); 393 | output.write(cursor.move(-999, prevLines.length - 1)); 394 | output.write(erase.down(prevLines.length)); 395 | }; 396 | 397 | const parseMessage = (msg: string): string => { 398 | return msg.replace(/\.+$/, ""); 399 | }; 400 | 401 | const formatTimer = (origin: number): string => { 402 | const duration = (performance.now() - origin) / 1000; 403 | const min = Math.floor(duration / 60); 404 | const secs = Math.floor(duration % 60); 405 | return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`; 406 | }; 407 | 408 | const start = (msg = ""): void => { 409 | isSpinnerActive = true; 410 | // @ts-ignore 411 | unblock = block({ output }); 412 | _message = parseMessage(msg); 413 | _origin = performance.now(); 414 | output.write(`${c.gray(S_BAR)}\n`); 415 | let frameIndex = 0; 416 | let indicatorTimer = 0; 417 | registerHooks(); 418 | loop = setInterval(() => { 419 | if (isCI && _message === _prevMessage) { 420 | return; 421 | } 422 | clearPrevMessage(); 423 | _prevMessage = _message; 424 | const frame = orange(frames[frameIndex]); 425 | 426 | if (isCI) { 427 | output.write(`${frame} ${_message}...`); 428 | } else if (indicator === "timer") { 429 | output.write(`${frame} ${_message} ${formatTimer(_origin)}`); 430 | } else { 431 | const loadingDots = ".".repeat(Math.floor(indicatorTimer)).slice(0, 3); 432 | output.write(`${frame} ${_message}${loadingDots}`); 433 | } 434 | 435 | frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0; 436 | indicatorTimer = 437 | indicatorTimer < frames.length ? indicatorTimer + 0.125 : 0; 438 | }, delay); 439 | }; 440 | 441 | const stop = (msg = "", code = 0): void => { 442 | isSpinnerActive = false; 443 | clearInterval(loop); 444 | clearPrevMessage(); 445 | const step = 446 | code === 0 447 | ? c.green(S_STEP_SUBMIT) 448 | : code === 1 449 | ? c.red(S_STEP_CANCEL) 450 | : c.red(S_STEP_ERROR); 451 | _message = parseMessage(msg ?? _message); 452 | if (indicator === "timer") { 453 | output.write(`${step} ${_message} ${formatTimer(_origin)}\n`); 454 | } else { 455 | output.write(`${step} ${_message}\n`); 456 | } 457 | clearHooks(); 458 | unblock(); 459 | }; 460 | 461 | const message = (msg = ""): void => { 462 | _message = parseMessage(msg ?? _message); 463 | }; 464 | 465 | return { 466 | start, 467 | stop, 468 | message, 469 | get isCancelled() { 470 | return isCancelled; 471 | }, 472 | }; 473 | } 474 | 475 | export interface MultiSelectOptions extends CommonOptions { 476 | message: string; 477 | options: Option[]; 478 | initialValues?: Value[]; 479 | maxItems?: number; 480 | required?: boolean; 481 | cursorAt?: Value; 482 | } 483 | export const multiselect = (opts: MultiSelectOptions) => { 484 | const opt = ( 485 | option: Option, 486 | state: 487 | | "inactive" 488 | | "active" 489 | | "selected" 490 | | "active-selected" 491 | | "submitted" 492 | | "cancelled", 493 | ) => { 494 | const label = option.label ?? String(option.value); 495 | if (state === "active") { 496 | return `${orange(S_CHECKBOX_ACTIVE)} ${label} ${ 497 | option.hint ? c.dim(`(${option.hint})`) : "" 498 | }`; 499 | } 500 | if (state === "selected") { 501 | return `${orange(S_CHECKBOX_SELECTED)} ${c.dim(label)} ${ 502 | option.hint ? c.dim(`(${option.hint})`) : "" 503 | }`; 504 | } 505 | if (state === "cancelled") { 506 | return `${c.strikethrough(c.dim(label))}`; 507 | } 508 | if (state === "active-selected") { 509 | return `${orange(S_CHECKBOX_SELECTED)} ${label} ${ 510 | option.hint ? c.dim(`(${option.hint})`) : "" 511 | }`; 512 | } 513 | if (state === "submitted") { 514 | return `${c.dim(label)}`; 515 | } 516 | return `${c.dim(S_CHECKBOX_INACTIVE)} ${c.dim(label)}`; 517 | }; 518 | 519 | return new MultiSelectPrompt({ 520 | options: opts.options, 521 | input: opts.input, 522 | output: opts.output, 523 | initialValues: opts.initialValues, 524 | required: opts.required ?? true, 525 | cursorAt: opts.cursorAt, 526 | validate(selected: Value[]) { 527 | if (this.required && selected.length === 0) 528 | return `Please select at least one option.\n${c.reset( 529 | c.dim( 530 | `Press ${c.gray(c.bgWhite(c.inverse(" space ")))} to select, ${c.gray( 531 | c.bgWhite(c.inverse(" enter ")), 532 | )} to submit`, 533 | ), 534 | )}`; 535 | }, 536 | render() { 537 | const active = this.state === "active" || this.state === "initial"; 538 | const title = `${c.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; 539 | const controls = `${active ? orange(S_BAR) : c.gray(S_BAR)} ${c.dim("(space to select, enter to submit)")}\n`; 540 | 541 | const styleOption = (option: Option, active: boolean) => { 542 | const selected = this.value.includes(option.value); 543 | if (active && selected) { 544 | return opt(option, "active-selected"); 545 | } 546 | if (selected) { 547 | return opt(option, "selected"); 548 | } 549 | return opt(option, active ? "active" : "inactive"); 550 | }; 551 | 552 | switch (this.state) { 553 | case "submit": { 554 | return `${title}${controls}${c.gray(S_BAR)} ${ 555 | this.options 556 | .filter(({ value }) => this.value.includes(value)) 557 | .map((option) => opt(option, "submitted")) 558 | .join(c.dim(", ")) || c.dim("none") 559 | }`; 560 | } 561 | case "cancel": { 562 | const label = this.options 563 | .filter(({ value }) => this.value.includes(value)) 564 | .map((option) => opt(option, "cancelled")) 565 | .join(c.dim(", ")); 566 | return `${title}${controls}${c.gray(S_BAR)} ${ 567 | label.trim() ? `${label}\n${c.gray(S_BAR)}` : "" 568 | }`; 569 | } 570 | case "error": { 571 | const footer = this.error 572 | .split("\n") 573 | .map((ln, i) => 574 | i === 0 ? `${c.yellow(S_BAR_END)} ${c.yellow(ln)}` : ` ${ln}`, 575 | ) 576 | .join("\n"); 577 | return `${title + controls + c.yellow(S_BAR)} ${limitOptions({ 578 | output: opts.output, 579 | options: this.options, 580 | cursor: this.cursor, 581 | maxItems: opts.maxItems, 582 | style: styleOption, 583 | }).join(`\n${c.yellow(S_BAR)} `)}\n${footer}\n`; 584 | } 585 | default: { 586 | return `${title}${controls}${orange(S_BAR)} ${limitOptions({ 587 | output: opts.output, 588 | options: this.options, 589 | cursor: this.cursor, 590 | maxItems: opts.maxItems, 591 | style: styleOption, 592 | }).join(`\n${orange(S_BAR)} `)}\n${orange(S_BAR_END)}\n`; 593 | } 594 | } 595 | }, 596 | }).prompt() as Promise; 597 | }; 598 | 599 | export async function loader( 600 | promise: Promise, 601 | opts: { 602 | start: string; 603 | success: (value: T) => string; 604 | error: string; 605 | }, 606 | ) { 607 | const r = spinner(); 608 | r.start(opts.start); 609 | try { 610 | const result = await promise; 611 | r.stop(opts.success(result)); 612 | return result; 613 | } catch (error) { 614 | r.stop(`${opts.error}: ${error}`, 1); 615 | throw error; 616 | } 617 | } 618 | 619 | export function log(...messages: unknown[]) { 620 | for (const message of messages) { 621 | const str = Array.isArray(message) 622 | ? message.join("\n") 623 | : typeof message === "object" 624 | ? util.inspect(message) 625 | : `${message}`; 626 | const lines = str.split("\n"); 627 | 628 | lines.forEach((line) => { 629 | console.log(`${c.gray(S_BAR)} ${line}`); 630 | }); 631 | } 632 | } 633 | 634 | export function step(message: string, compact = false) { 635 | if (!compact) { 636 | log(""); 637 | } 638 | console.log(`${symbol("submit")} ${message}`); 639 | if (!compact) { 640 | log(""); 641 | } 642 | } 643 | 644 | export function warn(message: string) { 645 | log(""); 646 | message.split("\n").forEach((line) => { 647 | console.log(`${c.yellow(S_WARN)} ${line}`); 648 | }); 649 | log(""); 650 | } 651 | 652 | export function error(message: string, compact = false) { 653 | if (!compact) { 654 | log(""); 655 | } 656 | console.error(`${symbol("error")} ${message}`); 657 | if (!compact) { 658 | log(""); 659 | } 660 | } 661 | 662 | export function assertNotCancelled(input: T | symbol): asserts input is T { 663 | if (isCancel(input)) { 664 | process.exit(0); 665 | } 666 | } 667 | --------------------------------------------------------------------------------