← vibesdk
import { createLogger } from './logger';
import { isDispatcherAvailable } from './utils/dispatcherUtils';
import { createApp } from './app';
// import * as Sentry from '@sentry/cloudflare';
// import { sentryOptions } from './observability/sentry';
import { DORateLimitStore as BaseDORateLimitStore } from './services/rate-limit/DORateLimitStore';
import { getPreviewDomain, getProtocolForHost, isSeparatePreviewDomain } from './utils/urls';
import { proxyToAiGateway } from './services/aigateway-proxy/controller';
import { isOriginAllowed } from './config/security';
import { isDev } from './utils/envs';
import { proxyToSandbox } from './services/sandbox/request-handler';
import { handleGitProtocolRequest, isGitProtocolRequest } from './api/handlers/git-protocol';
import {
handleSpacePreview,
matchSpacePreviewParams,
} from './api/handlers/space-preview';
import { getAgentStub } from './agents';
import { AppService } from './database/services/AppService';
import {
buildOwnerPreviewCookie,
readOwnerPreviewCookie,
readOwnerPreviewTokenFromQuery,
verifyOwnerPreviewToken,
} from './utils/ownerPreviewToken';
// Durable Object and Service exports
export { UserAppSandboxService } from './services/sandbox/sandboxSdkClient';
export { CodeGeneratorAgent } from './agents/core/codingAgent';
export { UserSecretsStore } from './services/secrets/UserSecretsStore';
// SpaceDO (same-worker integration) — git-backed files + preview/deploy for
// `think` apps. Re-exported so wrangler can bind it via durable_objects.bindings.
// AppDatabase is NOT exported here — it lives inside SpaceDO's Worker Loader
// as a synthetic-worker class. See space/src/space/app-database-source.ts.
export { SpaceDO } from '@space-do/space';
// ThinkAgent (@cloudflare/think) — the agentic loop harness (model ↔ tools).
// Bound as THINK_DO in wrangler.
export { ThinkAgent } from './agents/think/ThinkAgent';
// export const CodeGeneratorAgent = Sentry.instrumentDurableObjectWithSentry(sentryOptions, CodeGeneratorAgent);
// export const DORateLimitStore = Sentry.instrumentDurableObjectWithSentry(sentryOptions, BaseDORateLimitStore);
export const DORateLimitStore = BaseDORateLimitStore;
// Logger for the main application and handlers
const logger = createLogger('App');
function setOriginControl(env: Env, request: Request, currentHeaders: Headers): Headers {
const origin = request.headers.get('Origin');
if (origin && isOriginAllowed(env, origin)) {
currentHeaders.set('Access-Control-Allow-Origin', origin);
}
return currentHeaders;
}
/**
* Re-wrap a space-preview response with CORS headers so the main platform
* domain can cross-origin `fetch()` it (e.g. the preview-iframe availability
* probe). Allowed origins:
* - The main platform domain (prod & staging via `CUSTOM_DOMAIN`).
* - The preview domain itself (same-origin).
* - Any origin in local dev (`isDev`).
* WebSocket upgrade responses are returned untouched since their bodies
* cannot be reconstructed.
*/
function withPreviewCorsHeaders(env: Env, request: Request, response: Response): Response {
if (response.headers.get('Upgrade')?.toLowerCase() === 'websocket') {
return response;
}
const origin = request.headers.get('Origin');
const allowedOrigins = [env.CUSTOM_DOMAIN, getPreviewDomain(env)]
.filter((host): host is string => !!host && host.trim() !== '')
.map((host) => `${getProtocolForHost(host)}://${host}`);
const headers = new Headers(response.headers);
if (origin && (isDev(env) || allowedOrigins.includes(origin))) {
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Access-Control-Allow-Credentials', 'true');
headers.append('Vary', 'Origin');
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
/**
* Handles requests for user-deployed applications on subdomains.
* It first attempts to proxy to a live development sandbox. If that fails,
* it dispatches the request to a permanently deployed worker via namespaces.
* This function will NOT fall back to the main worker.
*
* @param request The incoming Request object.
* @param env The environment bindings.
* @returns A Response object from the sandbox, the dispatched worker, or an error.
*/
async function handleUserAppRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const { hostname } = url;
logger.info(`Handling user app request for: ${hostname}`);
// Check if this is an agent browser file serving request
// Pattern: b-{agentid}-{token}.{previewDomain}
const subdomain = hostname.split('.')[0];
if (subdomain.startsWith('b-')) {
// Extract agentId and token from subdomain
const withoutPrefix = subdomain.substring(2); // Remove 'b-'
const lastHyphenIndex = withoutPrefix.lastIndexOf('-');
if (lastHyphenIndex !== -1) {
const agentId = withoutPrefix.substring(0, lastHyphenIndex);
logger.info(`Agent browser file serving request for agent: ${agentId}`);
try {
const agentStub = await getAgentStub(env, agentId);
return await agentStub.handleBrowserFileServing(request);
} catch (error: any) {
logger.error(`Error forwarding to agent: ${error.message}`);
return new Response('Agent not found', { status: 404 });
}
}
}
// 1. Attempt to proxy to a live development sandbox.
// proxyToSandbox doesn't consume the request body on a miss, so no clone is needed here.
const sandboxResponse = await proxyToSandbox(request, env);
if (sandboxResponse) {
logger.info(`Serving response from sandbox for: ${hostname}`);
// If it was a websocket upgrade, we need to return the response as is
if (sandboxResponse.headers.get('Upgrade')?.toLowerCase() === 'websocket') {
logger.info(`Serving websocket response from sandbox for: ${hostname}`);
return sandboxResponse;
}
// Add headers to identify this as a sandbox response
let headers = new Headers(sandboxResponse.headers);
if (sandboxResponse.status === 500) {
headers.set('X-Preview-Type', 'sandbox-error');
} else {
headers.set('X-Preview-Type', 'sandbox');
}
headers = setOriginControl(env, request, headers);
headers.append('Vary', 'Origin');
headers.set('Access-Control-Expose-Headers', 'X-Preview-Type');
return new Response(sandboxResponse.body, {
status: sandboxResponse.status,
statusText: sandboxResponse.statusText,
headers,
});
}
// 2. If sandbox misses, attempt to dispatch to a deployed worker.
logger.info(`Sandbox miss for ${hostname}, attempting dispatch to permanent worker.`);
if (!isDispatcherAvailable(env)) {
logger.warn(`Dispatcher not available, cannot serve: ${hostname}`);
return new Response('This application is not currently available.', { status: 404 });
}
// Extract the app name (e.g., "xyz" from "xyz.build.cloudflare.dev").
// This subdomain equals the app's `deploymentId`.
const appName = subdomain;
// --- Visibility gate: private deployed apps are reachable by the owner only ---
// Consult the app's CURRENT visibility on every dispatch request (primary DB,
// no cache) so a public->private toggle takes effect within ~1s. Without this,
// toggling an app private still left the deployed worker URL publicly served.
const appService = new AppService(env);
const ownership = await appService.getAppOwnershipByDeploymentId(appName);
if (!ownership) {
// Fail closed: no owning app row for this deployment id.
logger.warn(`No app found for deployment '${appName}', refusing dispatch.`);
return new Response('This application is not currently available.', { status: 404 });
}
let ownerAuthedViaQuery = false;
if (ownership.visibility === 'private' && ownership.userId) {
// Owner access on preview subdomains: main-domain session cookies are not
// sent cross-subdomain, so accept a deployment-scoped owner-preview token
// (query param on first hit, then an HttpOnly subdomain cookie).
const queryToken = readOwnerPreviewTokenFromQuery(url);
const cookieToken = readOwnerPreviewCookie(request);
let ownerId: string | null = null;
if (queryToken) {
ownerId = await verifyOwnerPreviewToken(env, queryToken, appName);
if (ownerId) ownerAuthedViaQuery = true;
}
if (!ownerId && cookieToken) {
ownerId = await verifyOwnerPreviewToken(env, cookieToken, appName);
}
if (!ownerId || ownerId !== ownership.userId) {
// Indistinguishable from a non-existent app (do not confirm existence).
return new Response('This application is not currently available.', { status: 404 });
}
}
// public apps, anonymous apps (userId === null), and verified owners fall through.
const dispatcher = env['DISPATCHER'];
try {
const worker = dispatcher.get(appName);
const dispatcherResponse = await worker.fetch(request);
// Add headers to identify this as a dispatcher response
let headers = new Headers(dispatcherResponse.headers);
headers.set('X-Preview-Type', 'dispatcher');
headers = setOriginControl(env, request, headers);
headers.append('Vary', 'Origin');
headers.set('Access-Control-Expose-Headers', 'X-Preview-Type');
// Bootstrap the subdomain-scoped owner cookie so subsequent requests
// (iframe sub-resources, client fetches) don't need the query token.
if (ownerAuthedViaQuery) {
headers.append(
'Set-Cookie',
buildOwnerPreviewCookie({ token: readOwnerPreviewTokenFromQuery(url)!, secure: url.protocol === 'https:' }),
);
}
return new Response(dispatcherResponse.body, {
status: dispatcherResponse.status,
statusText: dispatcherResponse.statusText,
headers,
});
} catch (error: any) {
// This block catches errors if the binding doesn't exist or if worker.fetch() fails.
logger.warn(`Error dispatching to worker '${appName}': ${error.message}`);
return new Response('An error occurred while loading this application.', { status: 500 });
}
}
/**
* Main Worker fetch handler with robust, secure routing.
*/
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// logger.info(`Received request: ${request.method} ${request.url}`);
// --- Pre-flight Checks ---
// 1. Critical configuration check: Ensure custom domain is set.
const previewDomain = getPreviewDomain(env);
const separatePreviewDomain = isSeparatePreviewDomain(env);
if (!previewDomain || previewDomain.trim() === '') {
logger.error('FATAL: env.CUSTOM_DOMAIN is not configured in wrangler.toml or the Cloudflare dashboard.');
return new Response('Server configuration error: Application domain is not set.', { status: 500 });
}
const url = new URL(request.url);
const { hostname, pathname } = url;
// 2. Security: Immediately reject any requests made via an IP address.
const ipRegex = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
if (ipRegex.test(hostname)) {
return new Response('Access denied. Please use the assigned domain name.', { status: 403 });
}
// --- Domain-based Routing ---
// Normalize hostnames for both local development (localhost) and production.
const isMainDomainRequest =
hostname === env.CUSTOM_DOMAIN || hostname === 'localhost';
const isSubdomainRequest =
hostname.endsWith(`.${previewDomain}`) ||
(hostname.endsWith('.localhost') && hostname !== 'localhost');
if (separatePreviewDomain && hostname === previewDomain) {
const params = matchSpacePreviewParams(pathname);
if (!params) {
return new Response('Not Found', { status: 404 });
}
const response = await handleSpacePreview(request, env, params.spaceName, params.branch);
return withPreviewCorsHeaders(env, request, response);
}
// Route 1: Main Platform Request (e.g., build.cloudflare.dev or localhost)
if (isMainDomainRequest) {
const params = matchSpacePreviewParams(pathname);
if (params) {
if (separatePreviewDomain) {
return new Response('Not Found', { status: 404 });
}
const response = await handleSpacePreview(request, env, params.spaceName, params.branch);
return withPreviewCorsHeaders(env, request, response);
}
// Handle Git protocol endpoints directly
// Route: /apps/:id.git/info/refs or /apps/:id.git/git-upload-pack
if (isGitProtocolRequest(pathname)) {
return handleGitProtocolRequest(request, env, ctx);
}
// Cloudflare OAuth connect routes: handle via Hono app even though they are not under /api
if (pathname.startsWith('/oauth/') || pathname === '/auth/callback') {
// Do not log the full URL: /auth/callback carries sensitive
// query params (code, state) that must not end up in logs.
logger.info(`Handling Cloudflare OAuth request for: ${pathname}`);
const app = createApp(env);
return app.fetch(request, env, ctx);
}
// Serve static assets for all other non-API routes from the ASSETS binding.
if (!pathname.startsWith('/api/')) {
return env.ASSETS.fetch(request);
}
// AI Gateway proxy for generated apps
if (pathname.startsWith('/api/proxy/openai')) {
// Browser-originated requests must come from a preview-domain
// subdomain or an explicitly allowed origin. Server-side calls
// from generated apps carry no Origin header and are allowed
// through (auth is enforced by the app-proxy JWT downstream).
const origin = request.headers.get('Origin');
if (origin) {
const previewDomain = getPreviewDomain(env);
const originAllowed = isOriginAllowed(env, origin) || origin.endsWith(`.${previewDomain}`);
if (!originAllowed) {
logger.warn(`Access denied. Invalid origin: ${origin}, preview domain: ${previewDomain}`);
return new Response('Access denied. Invalid origin.', { status: 403 });
}
}
return proxyToAiGateway(request, env, ctx);
}
// Handle all API requests with the main Hono application.
// Log pathname only: some API routes (e.g. /api/auth/callback/:provider)
// carry sensitive query params like `code`/`state`.
logger.info(`Handling API request for: ${pathname}`);
const app = createApp(env);
return app.fetch(request, env, ctx);
}
// Route 2: User App Request (e.g., xyz.build.cloudflare.dev or test.localhost)
if (isSubdomainRequest) {
return handleUserAppRequest(request, env);
}
return new Response('Not Found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
export default worker;
// Wrap the entire worker with Sentry for comprehensive error monitoring.
// export default Sentry.withSentry(sentryOptions, worker);