From caf8ef8542d1d8d24202c028858f410106683c5b Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 4 Feb 2026 10:40:10 +0100 Subject: [PATCH 1/3] App Builder - Add preview URL tracking with navigation controls Migrated from Kilo-Org/kilocode-backend#4705 --- .../src/handlers/preview.ts | 141 +++++++++++++- .../app-builder/AppBuilderPreview.tsx | 172 +++++++++++++++--- src/components/app-builder/ProjectManager.ts | 10 + .../__tests__/messages.test.ts | 1 + .../__tests__/preview-polling.test.ts | 1 + .../project-manager/__tests__/store.test.ts | 1 + .../__tests__/streaming.test.ts | 1 + .../app-builder/project-manager/store.ts | 1 + .../app-builder/project-manager/types.ts | 2 + 9 files changed, 300 insertions(+), 30 deletions(-) diff --git a/cloudflare-app-builder/src/handlers/preview.ts b/cloudflare-app-builder/src/handlers/preview.ts index 97b962442a..3b39b3e934 100644 --- a/cloudflare-app-builder/src/handlers/preview.ts +++ b/cloudflare-app-builder/src/handlers/preview.ts @@ -5,6 +5,93 @@ import type { PreviewDO } from '../preview-do'; import { getSandbox } from '@cloudflare/sandbox'; import { switchPort } from '@cloudflare/containers'; +/** + * Adds a nonce to the script-src directive of a CSP header. + * If script-src doesn't exist, creates it based on default-src. + * Returns the modified CSP string. + */ +function addNonceToCSP(csp: string, nonce: string): string { + const nonceValue = `'nonce-${nonce}'`; + const directives = csp + .split(';') + .map(d => d.trim()) + .filter(Boolean); + + const directiveMap = new Map(); + for (const directive of directives) { + const spaceIndex = directive.indexOf(' '); + if (spaceIndex === -1) { + directiveMap.set(directive.toLowerCase(), ''); + } else { + const name = directive.slice(0, spaceIndex).toLowerCase(); + const value = directive.slice(spaceIndex + 1); + directiveMap.set(name, value); + } + } + + if (directiveMap.has('script-src')) { + const current = directiveMap.get('script-src') ?? ''; + directiveMap.set('script-src', `${current} ${nonceValue}`); + } else if (directiveMap.has('default-src')) { + // Create script-src from default-src and add nonce + const defaultSrc = directiveMap.get('default-src') ?? ''; + directiveMap.set('script-src', `${defaultSrc} ${nonceValue}`); + } else { + // No script-src or default-src, add script-src with nonce + directiveMap.set('script-src', nonceValue); + } + + // Reconstruct CSP string + const result: string[] = []; + for (const [name, value] of directiveMap) { + result.push(value ? `${name} ${value}` : name); + } + return result.join('; '); +} + +/** + * Bridge script injected into HTML responses to enable URL tracking. + * Sends navigation events to the parent window via postMessage. + * Validates message origins before navigating to prevent unauthorized control. + * Note: The nonce attribute is added dynamically at injection time. + */ +const PREVIEW_BRIDGE_SCRIPT = ``; + function getPreviewDO(appId: string, env: Env): DurableObjectStub { const id = env.PREVIEW.idFromName(appId); return env.PREVIEW.get(id); @@ -28,7 +115,13 @@ export async function handleGetPreviewStatus( const previewStub = getPreviewDO(appId, env); const { state, error } = await previewStub.getStatus(); - const previewUrl = state === 'running' ? `https://${appId}.${env.BUILDER_HOSTNAME}` : null; + // In dev mode, return URL without subdomain (worker routes based on last accessed project) + const previewUrl = + state === 'running' + ? env.DEV_MODE + ? `https://${env.BUILDER_HOSTNAME}` + : `https://${appId}.${env.BUILDER_HOSTNAME}` + : null; return new Response( JSON.stringify({ @@ -205,6 +298,52 @@ export async function handlePreviewProxy( try { const response = await sandbox.containerFetch(proxyRequest, port); + + // Inject preview bridge script into HTML responses for URL tracking + const contentType = response.headers.get('content-type'); + if (contentType?.includes('text/html')) { + const html = await response.text(); + + // Generate a nonce for CSP-safe script injection + const nonce = crypto.randomUUID(); + const scriptWithNonce = PREVIEW_BRIDGE_SCRIPT.replace( + '`; +} function getPreviewDO(appId: string, env: Env): DurableObjectStub { const id = env.PREVIEW.idFromName(appId); @@ -304,20 +335,17 @@ export async function handlePreviewProxy( if (contentType?.includes('text/html')) { const html = await response.text(); - // Generate a nonce for CSP-safe script injection - const nonce = crypto.randomUUID(); - const scriptWithNonce = PREVIEW_BRIDGE_SCRIPT.replace( - '