From defc1834040c1b4da2243c42abde2f755191ccb9 Mon Sep 17 00:00:00 2001 From: Stevan Kapicic Date: Thu, 11 Sep 2025 16:59:55 +0200 Subject: [PATCH 01/11] Add POC of proxying via iframe (WIP) --- app/api/proxy-content/[chatId]/route.ts | 246 ++++++++++++++++++ app/api/proxy/[chatId]/route.ts | 168 ++++++++++++ app/api/validate-url/route.ts | 32 +++ app/apps/[chatId]/AppViewer.tsx | 231 ++++++++++++++++ app/apps/[chatId]/page.tsx | 72 +++++ .../tabs/deployed-apps/AppViewButton.tsx | 76 ++++++ app/components/DeployedAppCard.tsx | 172 ++++++++++++ .../deployment/base-deployment-plugin.ts | 2 + .../deployment/netlify-deploy-plugin.ts | 114 +++++++- .../deployment/vercel-deploy-plugin.ts | 67 ++++- app/lib/utils/app-url.ts | 183 +++++++++++++ docs/reverse-proxy.md | 234 +++++++++++++++++ starters/next-starter/next.config.ts | 37 ++- 13 files changed, 1625 insertions(+), 9 deletions(-) create mode 100644 app/api/proxy-content/[chatId]/route.ts create mode 100644 app/api/proxy/[chatId]/route.ts create mode 100644 app/api/validate-url/route.ts create mode 100644 app/apps/[chatId]/AppViewer.tsx create mode 100644 app/apps/[chatId]/page.tsx create mode 100644 app/components/@settings/tabs/deployed-apps/AppViewButton.tsx create mode 100644 app/components/DeployedAppCard.tsx create mode 100644 app/lib/utils/app-url.ts create mode 100644 docs/reverse-proxy.md diff --git a/app/api/proxy-content/[chatId]/route.ts b/app/api/proxy-content/[chatId]/route.ts new file mode 100644 index 00000000..3143bc4b --- /dev/null +++ b/app/api/proxy-content/[chatId]/route.ts @@ -0,0 +1,246 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '~/lib/prisma'; +import { getSession } from '~/auth/session'; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ chatId: string }> }) { + try { + const { chatId } = await params; + const session = await getSession(request); + + // Find the website by chatId + const website = await prisma.website.findFirst({ + where: { + chatId, + OR: [{ isPublic: true }, ...(session?.user?.id ? [{ createdById: session.user.id }] : [])], + }, + }); + + if (!website || !website.siteUrl) { + return NextResponse.json({ success: false, error: 'Website not found or not deployed' }, { status: 404 }); + } + + // Get the path from the request URL + const { searchParams } = new URL(request.url); + const path = searchParams.get('path') || ''; + + // Construct the target URL + const targetUrl = new URL(path, website.siteUrl).toString(); + + // Set up headers for the request + const headers: Record = { + 'User-Agent': request.headers.get('user-agent') || 'Mozilla/5.0 (compatible; LibLab-Proxy/1.0)', + }; + + // Forward relevant headers + const headersToForward = [ + 'accept', + 'accept-language', + 'accept-encoding', + 'cache-control', + 'if-modified-since', + 'if-none-match', + ]; + + headersToForward.forEach((header) => { + const value = request.headers.get(header); + + if (value) { + headers[header] = value; + } + }); + + // Fetch the content from the target URL + const response = await fetch(targetUrl, { + method: 'GET', + headers, + // Add timeout + signal: AbortSignal.timeout(30000), // 30 seconds timeout + }); + + if (!response.ok) { + // Handle different error cases + if (response.status === 404) { + return NextResponse.json({ success: false, error: 'Content not found' }, { status: 404 }); + } + + if (response.status >= 500) { + return NextResponse.json({ success: false, error: 'Target server error' }, { status: 502 }); + } + + return NextResponse.json({ success: false, error: 'Failed to fetch content' }, { status: response.status }); + } + + // Get the content type + const contentType = response.headers.get('content-type') || 'text/html'; + + // Create response headers + const responseHeaders = new Headers(); + + // Copy relevant headers from the target response + const headersToCopy = [ + 'content-type', + 'content-length', + 'content-encoding', + 'cache-control', + 'etag', + 'last-modified', + 'expires', + 'content-disposition', + ]; + + headersToCopy.forEach((header) => { + const value = response.headers.get(header); + + if (value) { + responseHeaders.set(header, value); + } + }); + + // Add CORS headers + responseHeaders.set('Access-Control-Allow-Origin', '*'); + responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + responseHeaders.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); + + // Handle different content types + if (contentType.includes('text/html')) { + const html = await response.text(); + + // Process HTML to fix relative URLs + const processedHtml = processHtmlUrls(html, chatId); + + return new NextResponse(processedHtml, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } + + if (contentType.includes('application/json')) { + const json = await response.text(); + + // Process JSON to fix relative URLs + const processedJson = processJsonUrls(json, chatId); + + return new NextResponse(processedJson, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } + + // For other content types (images, CSS, JS, etc.), stream the response + const stream = new ReadableStream({ + start(controller) { + const reader = response.body?.getReader(); + + function pump(): Promise { + return reader!.read().then(({ done, value }) => { + if (done) { + controller.close(); + return undefined; + } + + controller.enqueue(value); + + return pump(); + }); + } + + return pump(); + }, + }); + + return new NextResponse(stream, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (error) { + console.error('Proxy content error:', error); + + if (error instanceof Error && error.name === 'TimeoutError') { + return NextResponse.json({ success: false, error: 'Request timeout' }, { status: 504 }); + } + + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }); + } +} + +function processHtmlUrls(html: string, chatId: string): string { + const baseUrl = `/api/proxy-content/${chatId}`; + + return ( + html + // Fix relative URLs in src attributes + .replace(/src="(?!https?:\/\/|\/\/)([^"]+)"/g, (match, url) => { + const encodedUrl = encodeURIComponent(url); + return `src="${baseUrl}?path=${encodedUrl}"`; + }) + // Fix relative URLs in href attributes + .replace(/href="(?!https?:\/\/|\/\/)([^"]+)"/g, (match, url) => { + const encodedUrl = encodeURIComponent(url); + return `href="${baseUrl}?path=${encodedUrl}"`; + }) + // Fix relative URLs in action attributes + .replace(/action="(?!https?:\/\/|\/\/)([^"]+)"/g, (match, url) => { + const encodedUrl = encodeURIComponent(url); + return `action="${baseUrl}?path=${encodedUrl}"`; + }) + // Fix relative URLs in CSS url() functions + .replace(/url\(['"]?(?!https?:\/\/|\/\/)([^'"]+)['"]?\)/g, (match, url) => { + const encodedUrl = encodeURIComponent(url); + return `url("${baseUrl}?path=${encodedUrl}")`; + }) + ); +} + +function processJsonUrls(json: string, chatId: string): string { + try { + const data = JSON.parse(json); + const processedData = processJsonObject(data, chatId); + + return JSON.stringify(processedData); + } catch { + // If JSON parsing fails, return original + return json; + } +} + +function processJsonObject(obj: any, chatId: string): any { + if (typeof obj === 'string') { + // Check if it looks like a relative URL + if (obj.startsWith('/') && !obj.startsWith('//')) { + const encodedUrl = encodeURIComponent(obj); + return `/api/proxy-content/${chatId}?path=${encodedUrl}`; + } + + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => processJsonObject(item, chatId)); + } + + if (obj && typeof obj === 'object') { + const processed: any = {}; + + for (const [key, value] of Object.entries(obj)) { + processed[key] = processJsonObject(value, chatId); + } + + return processed; + } + + return obj; +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With', + }, + }); +} diff --git a/app/api/proxy/[chatId]/route.ts b/app/api/proxy/[chatId]/route.ts new file mode 100644 index 00000000..8ac1843c --- /dev/null +++ b/app/api/proxy/[chatId]/route.ts @@ -0,0 +1,168 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '~/lib/prisma'; +import { getSession } from '~/auth/session'; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ chatId: string }> }) { + try { + const { chatId } = await params; + const session = await getSession(request); + + // Find the website by chatId + const website = await prisma.website.findFirst({ + where: { + chatId, + // Only show public websites or websites created by the current user + OR: [{ isPublic: true }, ...(session?.user?.id ? [{ createdById: session.user.id }] : [])], + }, + }); + + if (!website || !website.siteUrl) { + return NextResponse.json({ success: false, error: 'Website not found or not deployed' }, { status: 404 }); + } + + // Get the path from the request URL + const { searchParams } = new URL(request.url); + const path = searchParams.get('path') || ''; + const targetUrl = new URL(path, website.siteUrl).toString(); + + // Fetch the content from the target URL + const response = await fetch(targetUrl, { + method: request.method, + headers: { + // Forward relevant headers + 'User-Agent': request.headers.get('user-agent') || 'Mozilla/5.0', + Accept: request.headers.get('accept') || '*/*', + 'Accept-Language': request.headers.get('accept-language') || 'en-US,en;q=0.9', + 'Accept-Encoding': request.headers.get('accept-encoding') || 'gzip, deflate, br', + 'Cache-Control': request.headers.get('cache-control') || 'no-cache', + }, + }); + + if (!response.ok) { + return NextResponse.json( + { success: false, error: 'Failed to fetch content from target URL' }, + { status: response.status }, + ); + } + + // Get the content type + const contentType = response.headers.get('content-type') || 'text/html'; + + // Create response with proper headers + const proxyResponse = new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + }); + + // Copy relevant headers from the target response + const headersToForward = [ + 'content-type', + 'content-length', + 'content-encoding', + 'cache-control', + 'etag', + 'last-modified', + 'expires', + ]; + + headersToForward.forEach((header) => { + const value = response.headers.get(header); + + if (value) { + proxyResponse.headers.set(header, value); + } + }); + + // Add CORS headers + proxyResponse.headers.set('Access-Control-Allow-Origin', '*'); + proxyResponse.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + proxyResponse.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + // If it's HTML, we might want to modify it to fix relative URLs + if (contentType.includes('text/html')) { + const html = await response.text(); + + // Replace relative URLs with proxy URLs + const modifiedHtml = html + .replace(/src="(?!https?:\/\/)([^"]+)"/g, `src="/api/proxy/${chatId}?path=$1"`) + .replace(/href="(?!https?:\/\/)([^"]+)"/g, `href="/api/proxy/${chatId}?path=$1"`) + .replace(/action="(?!https?:\/\/)([^"]+)"/g, `action="/api/proxy/${chatId}?path=$1"`); + + return new NextResponse(modifiedHtml, { + status: response.status, + statusText: response.statusText, + headers: proxyResponse.headers, + }); + } + + return proxyResponse; + } catch (error) { + console.error('Proxy error:', error); + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ chatId: string }> }) { + try { + const { chatId } = await params; + const session = await getSession(request); + + // Find the website by chatId + const website = await prisma.website.findFirst({ + where: { + chatId, + OR: [{ isPublic: true }, ...(session?.user?.id ? [{ createdById: session.user.id }] : [])], + }, + }); + + if (!website || !website.siteUrl) { + return NextResponse.json({ success: false, error: 'Website not found or not deployed' }, { status: 404 }); + } + + // Get the path from the request URL + const { searchParams } = new URL(request.url); + const path = searchParams.get('path') || ''; + const targetUrl = new URL(path, website.siteUrl).toString(); + + // Get the request body + const body = await request.text(); + + // Forward the POST request + const response = await fetch(targetUrl, { + method: 'POST', + headers: { + 'Content-Type': request.headers.get('content-type') || 'application/x-www-form-urlencoded', + 'User-Agent': request.headers.get('user-agent') || 'Mozilla/5.0', + }, + body, + }); + + const responseText = await response.text(); + const contentType = response.headers.get('content-type') || 'text/html'; + + return new NextResponse(responseText, { + status: response.status, + statusText: response.statusText, + headers: { + 'Content-Type': contentType, + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); + } catch (error) { + console.error('Proxy POST error:', error); + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }); + } +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); +} diff --git a/app/api/validate-url/route.ts b/app/api/validate-url/route.ts new file mode 100644 index 00000000..b8d106dd --- /dev/null +++ b/app/api/validate-url/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateDeploymentUrl } from '~/lib/utils/app-url'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + + if (!url) { + return NextResponse.json({ success: false, error: 'URL parameter is required' }, { status: 400 }); + } + + // Validate the URL format + try { + new URL(url); + } catch { + return NextResponse.json({ success: false, error: 'Invalid URL format' }, { status: 400 }); + } + + // Validate the deployment URL + const result = await validateDeploymentUrl(url); + + return NextResponse.json({ + success: true, + valid: result.valid, + error: result.error, + }); + } catch (error) { + console.error('URL validation error:', error); + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/apps/[chatId]/AppViewer.tsx b/app/apps/[chatId]/AppViewer.tsx new file mode 100644 index 00000000..8e5d367c --- /dev/null +++ b/app/apps/[chatId]/AppViewer.tsx @@ -0,0 +1,231 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { AlertCircle, ExternalLink, Globe, Loader2, Maximize2, Minimize2, RefreshCw, Settings } from 'lucide-react'; +import { classNames } from '~/utils/classNames'; + +type Website = any; // TODO + +interface AppViewerProps { + website: Website; +} + +export default function AppViewer({ website }: AppViewerProps) { + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showInfo, setShowInfo] = useState(false); + const [lastRefresh, setLastRefresh] = useState(new Date()); + + const handleIframeLoad = () => { + setIsLoading(false); + setHasError(false); + }; + + const handleIframeError = () => { + setIsLoading(false); + setHasError(true); + }; + + const handleRefresh = () => { + setIsLoading(true); + setHasError(false); + setLastRefresh(new Date()); + + // Force iframe reload by updating the src + const iframe = document.getElementById('app-iframe') as HTMLIFrameElement; + + if (iframe) { + const currentSrc = iframe.src; + iframe.src = ''; + setTimeout(() => { + iframe.src = currentSrc; + }, 100); + } + }; + + const handleOpenExternal = () => { + if (website.siteUrl) { + window.open(website.siteUrl, '_blank', 'noopener,noreferrer'); + } + }; + + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + setIsFullscreen(true); + } else { + document.exitFullscreen(); + setIsFullscreen(false); + } + }; + + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener('fullscreenchange', handleFullscreenChange); + + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); + }, []); + + if (!website.siteUrl) { + return ( +
+
+ +

App Not Deployed

+

This app hasn't been deployed yet.

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

+ {website.siteName || 'Untitled App'} +

+

+ {website.environment?.name || 'Unknown Environment'} +

+
+
+ +
+ + + + + + + +
+
+
+ + {/* App Information Panel */} + {showInfo && ( + +
+
+ Created by: +

{website.createdBy.name}

+
+
+ Created: +

{new Date(website.createdAt).toLocaleDateString()}

+
+
+ Last refreshed: +

{lastRefresh.toLocaleTimeString()}

+
+
+
+ )} + + {/* Loading State */} + {isLoading && ( +
+
+ +

Loading app...

+
+
+ )} + + {/* Error State */} + {hasError && ( +
+
+ +

Failed to Load App

+

+ The deployed app couldn't be loaded. It might be offline or the URL is incorrect. +

+ +
+
+ )} + + {/* App Container */} +
+