diff --git a/src/routes/chat/components/preview-iframe.tsx b/src/routes/chat/components/preview-iframe.tsx index eae8d601..41a35864 100644 --- a/src/routes/chat/components/preview-iframe.tsx +++ b/src/routes/chat/components/preview-iframe.tsx @@ -55,12 +55,32 @@ export const PreviewIframe = forwardRef( // ==================================================================== /** - * Test if URL is accessible using a simple HEAD request - * Returns preview type if accessible, null otherwise + * Extract app ID from subdomain URL + * e.g., https://v1-app-xyz.vibesdk.com/ -> v1-app-xyz + */ + const getAppIdFromUrl = useCallback((url: string): string | null => { + try { + const urlObj = new URL(url); + return urlObj.hostname.split('.')[0]; + } catch { + return null; + } + }, []); + + /** + * Test if app preview is available using the direct status check endpoint + * This bypasses the subdomain entirely and checks sandbox/dispatcher directly */ const testAvailability = useCallback(async (url: string): Promise<'sandbox' | 'dispatcher' | null> => { try { - const response = await fetch(url, { + const appId = getAppIdFromUrl(url); + if (!appId) { + console.log('Invalid app URL:', url); + return null; + } + + const statusUrl = `/api/apps/${appId}/preview-status`; + const response = await fetch(statusUrl, { method: 'HEAD', mode: 'cors', // Using CORS to read security-validated headers cache: 'no-cache', diff --git a/worker/api/routes/index.ts b/worker/api/routes/index.ts index bf4bf005..67993e6f 100644 --- a/worker/api/routes/index.ts +++ b/worker/api/routes/index.ts @@ -10,6 +10,7 @@ import { setupGitHubExporterRoutes } from './githubExporterRoutes'; import { setupCodegenRoutes } from './codegenRoutes'; import { setupScreenshotRoutes } from './imagesRoutes'; import { setupSentryRoutes } from './sentryRoutes'; +import { setupPreviewProxyRoutes } from './previewProxyRoutes'; import { Hono } from "hono"; import { AppEnv } from "../../types/appenv"; import { setupStatusRoutes } from './statusRoutes'; @@ -58,4 +59,7 @@ export function setupRoutes(app: Hono): void { // Screenshot serving routes (public) setupScreenshotRoutes(app); + + // Preview proxy routes + setupPreviewProxyRoutes(app); } diff --git a/worker/api/routes/previewProxyRoutes.ts b/worker/api/routes/previewProxyRoutes.ts new file mode 100644 index 00000000..3f1aa870 --- /dev/null +++ b/worker/api/routes/previewProxyRoutes.ts @@ -0,0 +1,45 @@ +import { Hono } from 'hono'; +import { AppEnv } from '../../types/appenv'; +import { AuthConfig, setAuthLevel } from '../../middleware/auth/routeAuth'; +import { resolvePreview } from '../../utils/previewResolver'; +import { buildUserWorkerUrl } from '../../utils/urls'; + +/** + * Preview status routes - check if an app preview is available + * This directly checks sandbox/dispatcher without needing to access subdomain URLs + */ +export function setupPreviewProxyRoutes(app: Hono): void { + // Check preview availability for an app (HEAD request only) + app.on('HEAD', '/api/apps/:id/preview-status', setAuthLevel(AuthConfig.public), async (c) => { + const env = c.env; + const appId = c.req.param('id'); + + try { + // Create a clean, isolated HEAD request for testing + // This ensures no user cookies or sensitive headers are forwarded + const testUrl = buildUserWorkerUrl(env, appId); + const cleanRequest = new Request(testUrl, { + method: 'HEAD', + }); + + const result = await resolvePreview(appId, cleanRequest, env); + + if (!result.available) { + return new Response(null, { status: 404 }); + } + + // Return success with preview type header + const headers = new Headers(); + if (result.type) { + headers.set('X-Preview-Type', result.type); + headers.set('Access-Control-Expose-Headers', 'X-Preview-Type'); + } + + return new Response(null, { status: 200, headers }); + } catch (error: unknown) { + const err = error as Error; + console.error('Preview status check error:', err); + return new Response(null, { status: 500 }); + } + }); +} diff --git a/worker/index.ts b/worker/index.ts index 449b5a05..855a8b18 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,6 +1,5 @@ import { createLogger } from './logger'; import { SmartCodeGeneratorAgent } from './agents/core/smartGeneratorAgent'; -import { isDispatcherAvailable } from './utils/dispatcherUtils'; import { createApp } from './app'; // import * as Sentry from '@sentry/cloudflare'; // import { sentryOptions } from './observability/sentry'; @@ -8,8 +7,8 @@ import { DORateLimitStore as BaseDORateLimitStore } from './services/rate-limit/ import { getPreviewDomain } from './utils/urls'; import { proxyToAiGateway } from './services/aigateway-proxy/controller'; import { isOriginAllowed } from './config/security'; -import { proxyToSandbox } from './services/sandbox/request-handler'; import { handleGitProtocolRequest, isGitProtocolRequest } from './api/handlers/git-protocol'; +import { resolvePreview } from './utils/previewResolver'; // Durable Object and Service exports export { UserAppSandboxService, DeployerService } from './services/sandbox/sandboxSdkClient'; @@ -44,71 +43,40 @@ function setOriginControl(env: Env, request: Request, currentHeaders: Headers): async function handleUserAppRequest(request: Request, env: Env): Promise { const url = new URL(request.url); const { hostname } = url; + const appId = hostname.split('.')[0]; + logger.info(`Handling user app request for: ${hostname}`); - // 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, - }); + // Use shared preview resolver to get the response + const result = await resolvePreview(appId, request, env); + + if (!result.available || !result.response) { + logger.warn(`Preview not available for: ${appId}`); + const errorMessage = result.error || 'This application is not currently available.'; + return new Response(errorMessage, { status: 404 }); } - // 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 }); + // Handle websocket upgrades specially (return response as-is) + if (result.response.headers.get('Upgrade')?.toLowerCase() === 'websocket') { + logger.info(`Serving websocket response for: ${appId}`); + return result.response; } - // Extract the app name (e.g., "xyz" from "xyz.build.cloudflare.dev"). - const appName = hostname.split('.')[0]; - 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'); - - 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 }); + // Add CORS and preview type headers to the response + let headers = new Headers(result.response.headers); + + if (result.type) { + headers.set('X-Preview-Type', result.type); } + headers = setOriginControl(env, request, headers); + headers.append('Vary', 'Origin'); + headers.set('Access-Control-Expose-Headers', 'X-Preview-Type'); + + return new Response(result.response.body, { + status: result.response.status, + statusText: result.response.statusText, + headers, + }); } /** diff --git a/worker/utils/previewResolver.ts b/worker/utils/previewResolver.ts new file mode 100644 index 00000000..2302362e --- /dev/null +++ b/worker/utils/previewResolver.ts @@ -0,0 +1,72 @@ +import { proxyToSandbox } from '../services/sandbox/request-handler'; +import { isDispatcherAvailable } from './dispatcherUtils'; +import { createLogger } from '../logger'; + +const logger = createLogger('PreviewResolver'); + +export type PreviewType = 'sandbox' | 'sandbox-error' | 'dispatcher'; + +export interface PreviewResult { + available: boolean; + type?: PreviewType; + response?: Response; + error?: string; +} + +/** + * Resolve preview availability for an app by checking sandbox and dispatcher + * @param appId - The app identifier (subdomain) + * @param request - The incoming request + * @param env - Worker environment + * @returns PreviewResult with availability status and response + */ +export async function resolvePreview( + appId: string, + request: Request, + env: Env +): Promise { + // Try sandbox first + const sandboxResponse = await proxyToSandbox(request, env); + if (sandboxResponse) { + logger.info(`Preview available in sandbox for: ${appId}`); + + const type: PreviewType = sandboxResponse.status === 500 ? 'sandbox-error' : 'sandbox'; + + return { + available: sandboxResponse.status !== 500, + type, + response: sandboxResponse, + }; + } + + // Try dispatcher (deployed worker) + logger.info(`Sandbox miss for ${appId}, attempting dispatch to permanent worker`); + if (!isDispatcherAvailable(env)) { + logger.warn(`Dispatcher not available, cannot serve: ${appId}`); + return { + available: false, + error: 'This application is not currently available.', + }; + } + + try { + const dispatcher = env['DISPATCHER']; + const worker = dispatcher.get(appId); + const dispatcherResponse = await worker.fetch(request); + + logger.info(`Preview available in dispatcher for: ${appId}`); + + return { + available: dispatcherResponse.ok, + type: 'dispatcher', + response: dispatcherResponse, + }; + } catch (error: unknown) { + const err = error as Error; + logger.warn(`Error dispatching to worker '${appId}': ${err.message}`); + return { + available: false, + error: 'An error occurred while loading this application.', + }; + } +}