From 0209d1280220ed2aa64f62a504872ee32dafdb82 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:42:00 -0700 Subject: [PATCH 1/2] Deploy cloud app to Cloudflare Workers - Add @cloudflare/vite-plugin and wrangler, configure for CF Workers - Add wrangler.jsonc with Hyperdrive binding and executor.sh custom domain - Add service binding to marketing worker for route proxying - Update vite.config.ts: replace SPA mode with Cloudflare plugin - Update db.ts: use Hyperdrive binding, remove auto-migrations - Update start.ts: add marketing middleware to proxy /, /home, /setup, /_astro - Fix auth handler to use x-forwarded-proto for HTTPS redirect URIs - Remove 1Password plugin from cloud app - Add .env files to .gitignore --- .gitignore | 4 ++- apps/cloud/package.json | 8 +++-- apps/cloud/src/auth/handlers.ts | 3 +- apps/cloud/src/routeTree.gen.ts | 3 +- apps/cloud/src/services/db.ts | 31 +++++++++------- apps/cloud/src/start.ts | 53 +++++++++++++++++++++++++++- apps/cloud/vite.config.ts | 24 ++----------- apps/cloud/worker-configuration.d.ts | 15 ++++++++ apps/cloud/wrangler.jsonc | 25 +++++++++++++ bun.lock | 12 ++++--- 10 files changed, 133 insertions(+), 45 deletions(-) create mode 100644 apps/cloud/worker-configuration.d.ts create mode 100644 apps/cloud/wrangler.jsonc diff --git a/.gitignore b/.gitignore index bfbd0f0b12..9e9ca20060 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,6 @@ apps/desktop/resources/ .claude/ .nitro/ .output/ -.tanstack/ \ No newline at end of file +.tanstack/ +.env* +!.env.example \ No newline at end of file diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 7521aa8ac0..e9e680bf23 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "dependencies": { + "@cloudflare/vite-plugin": "^1.31.1", "@effect-atom/atom": "^0.5.0", "@effect-atom/atom-react": "^0.5.0", "@effect/platform": "catalog:", @@ -35,12 +36,15 @@ "@vitejs/plugin-react": "catalog:", "portless": "^0.10.1", "typescript": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "wrangler": "^4.81.0" }, "scripts": { "dev": "op run --env-file=.env -- portless run --name executor-cloud vite dev", "build": "vite build", - "start": "bun ./server.ts", + "preview": "vite preview", + "deploy": "vite build && wrangler deploy", + "cf-typegen": "wrangler types", "typecheck": "tsc --noEmit" } } diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 049281c1d0..6dc461fc97 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -32,7 +32,8 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( Effect.gen(function* () { const workos = yield* WorkOSAuth; const req = yield* HttpServerRequest.HttpServerRequest; - const origin = new URL(req.url, `http://${req.headers["host"]}`).origin; + const proto = req.headers["x-forwarded-proto"] ?? "https"; + const origin = new URL(req.url, `${proto}://${req.headers["host"]}`).origin; const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`); return HttpServerResponse.redirect(url, { status: 302 }); }), diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index 33b5b40663..d870d9f28f 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -147,11 +147,10 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' -import type { startInstance } from './start.ts' +import type { createStart } from '@tanstack/react-start' declare module '@tanstack/react-start' { interface Register { ssr: true router: Awaited> - config: Awaited> } } diff --git a/apps/cloud/src/services/db.ts b/apps/cloud/src/services/db.ts index c954ada5af..ea39fcb10a 100644 --- a/apps/cloud/src/services/db.ts +++ b/apps/cloud/src/services/db.ts @@ -1,9 +1,8 @@ // --------------------------------------------------------------------------- -// Database service — PGlite for dev, node-postgres for prod +// Database service — Hyperdrive on Cloudflare, node-postgres for local dev // --------------------------------------------------------------------------- import { Context, Effect, Layer } from "effect"; -import { resolve } from "node:path"; import * as sharedSchema from "@executor/storage-postgres/schema"; import * as cloudSchema from "./schema"; import type { DrizzleDb } from "@executor/storage-postgres"; @@ -13,10 +12,8 @@ const schema = { ...sharedSchema, ...cloudSchema }; export type { DrizzleDb }; -const MIGRATIONS_DIR = resolve( - import.meta.dirname, - "../../../../packages/core/storage-postgres/drizzle", -); +// Migrations are run out-of-band (e.g. via a separate script or CI step), +// not at request time — Cloudflare Workers cannot read the filesystem. type DbResource = { readonly db: DrizzleDb; @@ -24,26 +21,36 @@ type DbResource = { }; const createDbResource = async (): Promise => { - if (server.DATABASE_URL) { + // Resolve connection string: prefer Hyperdrive binding, fall back to DATABASE_URL env + let connectionString: string | undefined; + try { + const { env } = await import("cloudflare:workers"); + const hyperdrive = (env as any).HYPERDRIVE; + if (hyperdrive?.connectionString) { + connectionString = hyperdrive.connectionString; + } + } catch { + // Not running on Cloudflare — fall back to env var + } + connectionString ??= server.DATABASE_URL || undefined; + + if (connectionString) { const { drizzle } = await import("drizzle-orm/node-postgres"); - const { migrate } = await import("drizzle-orm/node-postgres/migrator"); const { Pool } = await import("pg"); - const pool = new Pool({ connectionString: server.DATABASE_URL }); + const pool = new Pool({ connectionString }); const db = drizzle(pool, { schema }) as DrizzleDb; - await migrate(db as any, { migrationsFolder: MIGRATIONS_DIR }); return { db, close: () => pool.end(), }; } + // Local dev fallback: PGlite const { PGlite } = await import("@electric-sql/pglite"); const { drizzle } = await import("drizzle-orm/pglite"); - const { migrate } = await import("drizzle-orm/pglite/migrator"); const dataDir = server.PGLITE_DATA_DIR; const client = new PGlite(dataDir); const db = drizzle(client, { schema }) as DrizzleDb; - await migrate(db, { migrationsFolder: MIGRATIONS_DIR }); return { db, close: async () => { diff --git a/apps/cloud/src/start.ts b/apps/cloud/src/start.ts index 15bf5a8cf1..46751c14cc 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -1,6 +1,57 @@ import { createMiddleware, createStart } from "@tanstack/react-start"; import { handleApiRequest } from "./api"; +// --------------------------------------------------------------------------- +// Marketing routes — proxied to the marketing worker via service binding +// --------------------------------------------------------------------------- + +const MARKETING_PATHS = ["/home", "/setup", "/api/detect", "/_astro", "/favicon.ico", "/favicon.svg"]; + +const isMarketingPath = (pathname: string) => + MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); + +const getMarketingWorker = async () => { + try { + const { env } = await import("cloudflare:workers"); + return (env as any).MARKETING as { fetch: typeof fetch } | undefined; + } catch { + return undefined; + } +}; + +const marketingMiddleware = createMiddleware({ type: "request" }).server( + async ({ pathname, request, next }) => { + const shouldProxyToMarketing = + isMarketingPath(pathname) || + (pathname === "/" && !parseCookie(request.headers.get("cookie"), "wos-session")); + + if (!shouldProxyToMarketing) return next(); + + const marketing = await getMarketingWorker(); + if (!marketing) return next(); + + // Rewrite path: if user hits "/" without auth, serve marketing homepage + const url = new URL(request.url); + if (pathname === "/") { + url.pathname = "/"; + } + return marketing.fetch(new Request(url, request)); + }, +); + +const parseCookie = (cookieHeader: string | null, name: string): string | null => { + if (!cookieHeader) return null; + const match = cookieHeader + .split(";") + .map((v) => v.trim()) + .find((v) => v.startsWith(`${name}=`)); + return match ? match.slice(name.length + 1) || null : null; +}; + +// --------------------------------------------------------------------------- +// API middleware — routes /api/* to the Effect HTTP layer +// --------------------------------------------------------------------------- + const apiRequestMiddleware = createMiddleware({ type: "request" }).server( ({ pathname, request, next }) => { if (pathname === "/api" || pathname.startsWith("/api/")) { @@ -13,5 +64,5 @@ const apiRequestMiddleware = createMiddleware({ type: "request" }).server( ); export const startInstance = createStart(() => ({ - requestMiddleware: [apiRequestMiddleware], + requestMiddleware: [marketingMiddleware, apiRequestMiddleware], })); diff --git a/apps/cloud/vite.config.ts b/apps/cloud/vite.config.ts index 17162a3b6b..bc68521a05 100644 --- a/apps/cloud/vite.config.ts +++ b/apps/cloud/vite.config.ts @@ -1,33 +1,15 @@ import { defineConfig } from "vite"; +import { cloudflare } from "@cloudflare/vite-plugin"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -import { createEnv, Env } from "@executor/env"; - -const server = { - PORT: Env.numberOr("PORT", 5173), -}; - -type ViteEnv = Readonly<{ - PORT: number; -}>; - -const viteEnv = createEnv(server, { - runtimeEnv: process.env, - emptyStringAsUndefined: true, -}) as ViteEnv; export default defineConfig({ - server: { - port: viteEnv.PORT, - host: "127.0.0.1", - }, resolve: { tsconfigPaths: true }, plugins: [ tailwindcss(), - tanstackStart({ - spa: { enabled: true }, - }), + cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }), + tanstackStart(), react(), ], }); diff --git a/apps/cloud/worker-configuration.d.ts b/apps/cloud/worker-configuration.d.ts new file mode 100644 index 0000000000..8f3440292c --- /dev/null +++ b/apps/cloud/worker-configuration.d.ts @@ -0,0 +1,15 @@ +// Generated by wrangler types (run `bun run cf-typegen` to regenerate) + +declare namespace Cloudflare { + interface Env { + HYPERDRIVE: Hyperdrive; + MARKETING: Fetcher; + WORKOS_API_KEY: string; + WORKOS_CLIENT_ID: string; + WORKOS_COOKIE_PASSWORD: string; + ENCRYPTION_KEY: string; + NODE_ENV: string; + } +} + +interface Env extends Cloudflare.Env {} diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc new file mode 100644 index 0000000000..fbcc3a7bde --- /dev/null +++ b/apps/cloud/wrangler.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "executor-cloud", + "compatibility_date": "2025-04-01", + "compatibility_flags": ["nodejs_compat"], + "main": "@tanstack/react-start/server-entry", + "routes": [ + { "pattern": "executor.sh", "custom_domain": true } + ], + "observability": { + "enabled": true + }, + "services": [ + { + "binding": "MARKETING", + "service": "executor-marketing" + } + ], + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "776c27dfec5f47f59343603b35a7b4c2" + } + ] +} diff --git a/bun.lock b/bun.lock index 29da1a4c36..986e007412 100644 --- a/bun.lock +++ b/bun.lock @@ -43,6 +43,7 @@ "name": "@executor/cloud", "version": "1.4.0", "dependencies": { + "@cloudflare/vite-plugin": "^1.31.1", "@effect-atom/atom": "^0.5.0", "@effect-atom/atom-react": "^0.5.0", "@effect/platform": "catalog:", @@ -75,6 +76,7 @@ "portless": "^0.10.1", "typescript": "catalog:", "vite": "catalog:", + "wrangler": "^4.81.0", }, }, "apps/desktop": { @@ -3598,7 +3600,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], @@ -3652,12 +3654,14 @@ "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], - "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "@effect/platform-node/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "@effect/platform-node-shared/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -3942,8 +3946,6 @@ "miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], - "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], - "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], From ef516eef10a87e5835c395aac737e1f6e75aa077 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:47:10 -0700 Subject: [PATCH 2/2] Refactor db.ts to idiomatic Effect with scoped resources --- apps/cloud/src/services/db.ts | 104 +++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 47 deletions(-) diff --git a/apps/cloud/src/services/db.ts b/apps/cloud/src/services/db.ts index ea39fcb10a..a84f03eb39 100644 --- a/apps/cloud/src/services/db.ts +++ b/apps/cloud/src/services/db.ts @@ -1,6 +1,9 @@ // --------------------------------------------------------------------------- // Database service — Hyperdrive on Cloudflare, node-postgres for local dev // --------------------------------------------------------------------------- +// +// Migrations are run out-of-band (e.g. via a separate script or CI step), +// not at request time — Cloudflare Workers cannot read the filesystem. import { Context, Effect, Layer } from "effect"; import * as sharedSchema from "@executor/storage-postgres/schema"; @@ -12,70 +15,77 @@ const schema = { ...sharedSchema, ...cloudSchema }; export type { DrizzleDb }; -// Migrations are run out-of-band (e.g. via a separate script or CI step), -// not at request time — Cloudflare Workers cannot read the filesystem. - -type DbResource = { - readonly db: DrizzleDb; - readonly close: () => Promise; -}; +// --------------------------------------------------------------------------- +// Connection string resolution +// --------------------------------------------------------------------------- -const createDbResource = async (): Promise => { - // Resolve connection string: prefer Hyperdrive binding, fall back to DATABASE_URL env - let connectionString: string | undefined; - try { +const resolveHyperdriveUrl = Effect.tryPromise({ + try: async () => { const { env } = await import("cloudflare:workers"); const hyperdrive = (env as any).HYPERDRIVE; - if (hyperdrive?.connectionString) { - connectionString = hyperdrive.connectionString; - } - } catch { - // Not running on Cloudflare — fall back to env var - } - connectionString ??= server.DATABASE_URL || undefined; - - if (connectionString) { + return (hyperdrive?.connectionString as string) ?? null; + }, + catch: () => null, +}).pipe(Effect.map((v) => v ?? undefined)); + +const resolveConnectionString = resolveHyperdriveUrl.pipe( + Effect.map((url) => url ?? (server.DATABASE_URL || undefined)), +); + +// --------------------------------------------------------------------------- +// Postgres via node-postgres (used with Hyperdrive or DATABASE_URL) +// --------------------------------------------------------------------------- + +const acquirePostgres = (connectionString: string) => + Effect.tryPromise(async () => { const { drizzle } = await import("drizzle-orm/node-postgres"); const { Pool } = await import("pg"); const pool = new Pool({ connectionString }); - const db = drizzle(pool, { schema }) as DrizzleDb; - return { - db, - close: () => pool.end(), - }; - } - - // Local dev fallback: PGlite + return { db: drizzle(pool, { schema }) as DrizzleDb, pool }; + }); + +const releasePostgres = ({ pool }: { pool: { end: () => Promise } }) => + Effect.promise(() => pool.end()).pipe(Effect.orElseSucceed(() => undefined)); + +// --------------------------------------------------------------------------- +// PGlite — local dev fallback +// --------------------------------------------------------------------------- + +const acquirePglite = Effect.tryPromise(async () => { const { PGlite } = await import("@electric-sql/pglite"); const { drizzle } = await import("drizzle-orm/pglite"); - const dataDir = server.PGLITE_DATA_DIR; - const client = new PGlite(dataDir); - const db = drizzle(client, { schema }) as DrizzleDb; - return { - db, - close: async () => { - const closeClient = client.close; - if (closeClient) { - await closeClient.call(client); - } - }, - }; -}; + const client = new PGlite(server.PGLITE_DATA_DIR); + return { db: drizzle(client, { schema }) as DrizzleDb, client }; +}); -const closeDbResource = (resource: DbResource) => - Effect.promise(() => resource.close()).pipe( +const releasePglite = ({ client }: { client: { close?: () => Promise } }) => + Effect.promise(() => client.close?.() ?? Promise.resolve()).pipe( Effect.orElseSucceed(() => undefined), ); +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + export class DbService extends Context.Tag("@executor/cloud/DbService")< DbService, DrizzleDb >() { static Live = Layer.scoped( this, - Effect.acquireRelease( - Effect.promise(() => createDbResource()), - closeDbResource, - ).pipe(Effect.map((resource) => resource.db)), + Effect.gen(function* () { + const connectionString = yield* resolveConnectionString; + + if (connectionString) { + const { db } = yield* Effect.acquireRelease( + acquirePostgres(connectionString), + releasePostgres, + ); + return db; + } + + const { db } = yield* Effect.acquireRelease(acquirePglite, releasePglite); + return db; + }), ); }