Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,6 @@ apps/desktop/resources/
.claude/
.nitro/
.output/
.tanstack/
.tanstack/
.env*
!.env.example
8 changes: 6 additions & 2 deletions apps/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}),
Expand Down
3 changes: 1 addition & 2 deletions apps/cloud/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,10 @@ export const routeTree = rootRouteImport
._addFileTypes<FileRouteTypes>()

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<ReturnType<typeof getRouter>>
config: Awaited<ReturnType<typeof startInstance.getOptions>>
}
}
99 changes: 58 additions & 41 deletions apps/cloud/src/services/db.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// ---------------------------------------------------------------------------
// Database service — PGlite for dev, node-postgres for prod
// 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 { resolve } from "node:path";
import * as sharedSchema from "@executor/storage-postgres/schema";
import * as cloudSchema from "./schema";
import type { DrizzleDb } from "@executor/storage-postgres";
Expand All @@ -13,62 +15,77 @@ const schema = { ...sharedSchema, ...cloudSchema };

export type { DrizzleDb };

const MIGRATIONS_DIR = resolve(
import.meta.dirname,
"../../../../packages/core/storage-postgres/drizzle",
// ---------------------------------------------------------------------------
// Connection string resolution
// ---------------------------------------------------------------------------

const resolveHyperdriveUrl = Effect.tryPromise({
try: async () => {
const { env } = await import("cloudflare:workers");
const hyperdrive = (env as any).HYPERDRIVE;
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)),
);

type DbResource = {
readonly db: DrizzleDb;
readonly close: () => Promise<void>;
};
// ---------------------------------------------------------------------------
// Postgres via node-postgres (used with Hyperdrive or DATABASE_URL)
// ---------------------------------------------------------------------------

const createDbResource = async (): Promise<DbResource> => {
if (server.DATABASE_URL) {
const acquirePostgres = (connectionString: string) =>
Effect.tryPromise(async () => {
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 db = drizzle(pool, { schema }) as DrizzleDb;
await migrate(db as any, { migrationsFolder: MIGRATIONS_DIR });
return {
db,
close: () => pool.end(),
};
}
const pool = new Pool({ connectionString });
return { db: drizzle(pool, { schema }) as DrizzleDb, pool };
});

const releasePostgres = ({ pool }: { pool: { end: () => Promise<void> } }) =>
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 { 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 () => {
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<void> } }) =>
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;
}),
);
}
53 changes: 52 additions & 1 deletion apps/cloud/src/start.ts
Original file line number Diff line number Diff line change
@@ -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/")) {
Expand All @@ -13,5 +64,5 @@ const apiRequestMiddleware = createMiddleware({ type: "request" }).server(
);

export const startInstance = createStart(() => ({
requestMiddleware: [apiRequestMiddleware],
requestMiddleware: [marketingMiddleware, apiRequestMiddleware],
}));
24 changes: 3 additions & 21 deletions apps/cloud/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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(),
],
});
15 changes: 15 additions & 0 deletions apps/cloud/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -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 {}
25 changes: 25 additions & 0 deletions apps/cloud/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
12 changes: 7 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.