diff --git a/cloudflare-app-builder/src/handlers/preview.ts b/cloudflare-app-builder/src/handlers/preview.ts index 97b962442a..189b7981eb 100644 --- a/cloudflare-app-builder/src/handlers/preview.ts +++ b/cloudflare-app-builder/src/handlers/preview.ts @@ -5,6 +5,124 @@ import type { PreviewDO } from '../preview-do'; import { getSandbox } from '@cloudflare/sandbox'; import { switchPort } from '@cloudflare/containers'; +/** + * Generates a cryptographically secure base64-encoded nonce for CSP. + * Uses 16 random bytes (128 bits) encoded as base64. + */ +function generateCSPNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return btoa(String.fromCharCode(...bytes)); +} + +/** + * Adds a nonce to a CSP directive value. + * Handles the special case where 'none' is present (must be replaced, not appended). + */ +function addNonceToDirective(value: string, nonceValue: string): string { + // If directive contains 'none', replace it with the nonce (since 'none' must be alone) + if (value.includes("'none'")) { + return value.replace("'none'", nonceValue); + } + return `${value} ${nonceValue}`; +} + +/** + * Adds a nonce to the script-src directive of a CSP header. + * Also updates script-src-elem if present (since it takes precedence for `; +} + function getPreviewDO(appId: string, env: Env): DurableObjectStub { const id = env.PREVIEW.idFromName(appId); return env.PREVIEW.get(id); @@ -28,7 +146,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 +329,71 @@ export async function handlePreviewProxy( try { const response = await sandbox.containerFetch(proxyRequest, port); + + // Inject preview bridge script into HTML responses for URL tracking + // Uses HTMLRewriter for streaming transformation (avoids buffering entire response) + const contentType = response.headers.get('content-type'); + if (contentType?.includes('text/html')) { + // Generate a base64 nonce for CSP-safe script injection + const nonce = generateCSPNonce(); + const bridgeScript = getPreviewBridgeScript(nonce); + + const newHeaders = new Headers(response.headers); + newHeaders.delete('content-length'); + newHeaders.delete('content-encoding'); + + // Modify CSP headers to allow our nonced script + const csp = response.headers.get('content-security-policy'); + if (csp) { + newHeaders.set('content-security-policy', addNonceToCSP(csp, nonce)); + } + const cspReportOnly = response.headers.get('content-security-policy-report-only'); + if (cspReportOnly) { + newHeaders.set( + 'content-security-policy-report-only', + addNonceToCSP(cspReportOnly, nonce) + ); + } + + // Track whether we've injected the script (only inject once) + let injected = false; + + const rewriter = new HTMLRewriter() + // Prefer injecting at end of (best practice for scripts) + .on('head', { + element(element) { + if (!injected) { + element.append(bridgeScript, { html: true }); + injected = true; + } + }, + }) + // Fallback: inject at start of if no + .on('body', { + element(element) { + if (!injected) { + element.prepend(bridgeScript, { html: true }); + injected = true; + } + }, + }) + // Final fallback: append to document end if no or + .onDocument({ + end(end) { + if (!injected) { + end.append(bridgeScript, { html: true }); + } + }, + }); + + const transformedResponse = rewriter.transform(response); + return new Response(transformedResponse.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + } + return response; } catch (error) { logger.error('Container proxy error', formatError(error)); diff --git a/src/components/app-builder/AppBuilderPreview.tsx b/src/components/app-builder/AppBuilderPreview.tsx index 4025d2500c..520481e0b0 100644 --- a/src/components/app-builder/AppBuilderPreview.tsx +++ b/src/components/app-builder/AppBuilderPreview.tsx @@ -8,7 +8,7 @@ 'use client'; -import { useState, useCallback, useEffect, memo } from 'react'; +import { useState, useCallback, useEffect, useRef, memo } from 'react'; import Link from 'next/link'; import { Loader2, @@ -18,6 +18,9 @@ import { ExternalLink, AlertCircle, Rocket, + Home, + Copy, + Check, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -111,8 +114,13 @@ function IframeLoadingOverlay() { type PreviewFrameProps = { url: string; + currentPath: string; + isAtRoot: boolean; isFullscreen: boolean; + iframeRef: React.RefObject; onRefresh: () => void; + onGoHome: () => void; + onCopyUrl: () => Promise; onToggleFullscreen: () => void; onOpenExternal: () => void; }; @@ -121,18 +129,50 @@ type PreviewFrameProps = { * Preview frame controls bar */ function PreviewControls({ - url, + currentPath, + isAtRoot, isFullscreen, onRefresh, + onGoHome, + onCopyUrl, onToggleFullscreen, onOpenExternal, -}: PreviewFrameProps) { +}: Omit) { + const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef | null>(null); + + // Clear timeout on unmount to prevent setState on unmounted component + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + }; + }, []); + + const handleCopy = useCallback(async () => { + const success = await onCopyUrl(); + if (success) { + setCopied(true); + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500); + } + }, [onCopyUrl]); + return (
- {url} + + {currentPath}
+ @@ -152,29 +192,22 @@ function PreviewControls({ ); } -const isDev = process.env.NODE_ENV === 'development'; - -/** In dev, remove subdomain: "https://app-id.builder.kiloapps.io/path" -> "https://builder.kiloapps.io/" */ -function getPreviewUrl(url: string): string { - if (!isDev) return url; - try { - const parsed = new URL(url); - const parts = parsed.hostname.split('.'); - if (parts.length > 2) { - parsed.hostname = parts.slice(1).join('.'); - } - parsed.pathname = '/'; - return parsed.toString(); - } catch { - return url; - } -} - /** * Preview iframe with controls - renders dev or production iframe based on environment */ function PreviewFrame(props: PreviewFrameProps) { - const { url, isFullscreen } = props; + const { + url, + currentPath, + isAtRoot, + isFullscreen, + iframeRef, + onRefresh, + onGoHome, + onCopyUrl, + onToggleFullscreen, + onOpenExternal, + } = props; const [isIframeLoading, setIsIframeLoading] = useState(true); // Reset loading state when URL changes @@ -188,11 +221,21 @@ function PreviewFrame(props: PreviewFrameProps) { return (
- +
{isIframeLoading && }