diff --git a/platform/wab/src/wab/server/hosting/deploy.ts b/platform/wab/src/wab/server/hosting/deploy.ts new file mode 100644 index 000000000..d66c6c734 --- /dev/null +++ b/platform/wab/src/wab/server/hosting/deploy.ts @@ -0,0 +1,294 @@ +import { unbundleProjectFromData } from "@/wab/server/db/DbBundleLoader"; +import { DbMgr } from "@/wab/server/db/DbMgr"; +import { genLoaderHtmlBundle } from "@/wab/server/loader/gen-html-bundle"; +import { ProjectId } from "@/wab/shared/ApiSchema"; +import { Bundler } from "@/wab/shared/bundler"; +import { ensure } from "@/wab/shared/common"; +import { isPageComponent } from "@/wab/shared/core/components"; +import S3 from "aws-sdk/clients/s3"; +import { v4 as uuidv4 } from "uuid"; + +// Configuration +const HOSTING_BUCKET = + process.env.PLASMIC_HOSTING_BUCKET || "plasmic-hosting-sites"; + +export interface DeploymentResult { + deploymentId: string; + successfulDomains: { domain: string }[]; + failedDomains: { domain: string; error: any }[]; +} + +export interface PageAsset { + path: string; + html: string; + componentName: string; +} + +export interface DeploymentAssets { + pages: PageAsset[]; + metadata: { + projectId: string; + version: string; + generatedAt: Date; + favicon?: string; + }; +} + +/** + * Main deployment function - simplified for static content only + */ +export async function deployProjectToHosting( + mgr: DbMgr, + projectId: ProjectId, + domains: string[] +): Promise { + const deploymentId = uuidv4(); + const successfulDomains: { domain: string }[] = []; + const failedDomains: { domain: string; error: any }[] = []; + + try { + // 1. Generate static assets for all pages + console.log(`Generating static assets for project ${projectId}`); + const assets = await generateStaticAssets(mgr, projectId); + + // 2. Upload to S3 + console.log(`Uploading assets to S3 for deployment ${deploymentId}`); + await uploadToS3(assets, projectId, deploymentId); + + // 3. Configure CDN for each domain (simplified - just log for now) + for (const domain of domains) { + try { + console.log(`Configuring CDN for domain ${domain}`); + await configureCDNForDomain(domain, projectId, deploymentId); + successfulDomains.push({ domain }); + } catch (error) { + console.error(`Failed to configure CDN for ${domain}:`, error); + failedDomains.push({ domain, error }); + } + } + + // 4. Store deployment record + await storeDeploymentRecord(mgr, { + deploymentId, + projectId, + domains, + status: "success", + createdAt: new Date(), + }); + + return { + deploymentId, + successfulDomains, + failedDomains, + }; + } catch (error) { + console.error("Deployment failed:", error); + + await storeDeploymentRecord(mgr, { + deploymentId, + projectId, + domains, + status: "failed", + createdAt: new Date(), + error: error.message, + }); + + throw error; + } +} + +/** + * Generate static assets for a Plasmic project + */ +async function generateStaticAssets( + mgr: DbMgr, + projectId: ProjectId +): Promise { + const project = await mgr.getProjectById(projectId); + const projectToken = ensure( + project.projectApiToken, + "Project API token not found" + ); + + // Get latest revision + const latestRev = await mgr.getLatestProjectRev(projectId); + + // Create a bundler instance + const bundler = new Bundler(); + + // Unbundle the project to get the Site object + const site = await unbundleProjectFromData(mgr, bundler, latestRev); + + // Extract all pages from the site + const pages: Array<{ name: string; path: string }> = []; + + for (const component of site.components) { + if (isPageComponent(component)) { + const pageMeta = component.pageMeta; + pages.push({ + name: component.name, + // Use the page path from pageMeta, or generate from name + path: + pageMeta?.path || + `/${component.name.toLowerCase().replace(/\s+/g, "-")}`, + }); + } + } + + // Ensure we have a homepage + if (pages.length > 0 && !pages.find((p) => p.path === "/")) { + const homepage = pages.find( + (p) => + p.name.toLowerCase() === "homepage" || + p.name.toLowerCase() === "home" || + p.name.toLowerCase() === "index" + ); + if (homepage) { + homepage.path = "/"; + } else { + // If no obvious homepage, use the first page + pages[0].path = "/"; + } + } + + console.log( + `Found ${pages.length} pages in project ${projectId}:`, + pages.map((p) => `${p.name} (${p.path})`) + ); + + const assets: DeploymentAssets = { + pages: [], + metadata: { + projectId, + version: `rev-${latestRev.revision}`, + generatedAt: new Date(), + }, + }; + + // Generate HTML for each page + for (const page of pages) { + try { + const htmlBundle = await genLoaderHtmlBundle({ + projectId, + component: page.name, + projectToken, + hydrate: true, + embedHydrate: true, + prepass: true, + }); + + assets.pages.push({ + path: page.path, + html: htmlBundle, + componentName: page.name, + }); + + console.log(`Generated HTML for page ${page.name} at ${page.path}`); + } catch (error) { + console.error(`Failed to generate HTML for page ${page.name}:`, error); + } + } + + return assets; +} + +/** + * Upload deployment assets to S3 + */ +async function uploadToS3( + assets: DeploymentAssets, + projectId: string, + deploymentId: string +): Promise { + const s3 = new S3(); + const prefix = `sites/${projectId}/${deploymentId}`; + + // Upload pages + for (const page of assets.pages) { + const key = + page.path === "/" ? `${prefix}/index.html` : `${prefix}${page.path}.html`; + + await s3 + .putObject({ + Bucket: HOSTING_BUCKET, + Key: key, + Body: page.html, + ContentType: "text/html; charset=utf-8", + CacheControl: "public, max-age=3600", // 1 hour cache for HTML + }) + .promise(); + } + + // Upload deployment manifest + await s3 + .putObject({ + Bucket: HOSTING_BUCKET, + Key: `${prefix}/_plasmic/manifest.json`, + Body: JSON.stringify({ + ...assets.metadata, + deploymentId, + pages: assets.pages.map((p) => ({ + path: p.path, + component: p.componentName, + })), + }), + ContentType: "application/json", + CacheControl: "public, max-age=300", // 5 minutes + }) + .promise(); +} + +/** + * Configure CDN for a domain (simplified for now) + */ +async function configureCDNForDomain( + domain: string, + projectId: string, + deploymentId: string +): Promise { + // For now, just log what would be done + console.log(`Configuring CDN for ${domain}:`); + console.log( + ` - S3 Origin: ${HOSTING_BUCKET}/sites/${projectId}/${deploymentId}` + ); + console.log(` - Domain alias: ${domain}`); + + // For plasmic.run subdomains, we can automate DNS + if (domain.endsWith(".plasmic.run")) { + console.log(` - Would configure DNS for plasmic.run subdomain`); + } +} + +/** + * Store deployment record in database + */ +async function storeDeploymentRecord( + mgr: DbMgr, + record: { + deploymentId: string; + projectId: string; + domains: string[]; + status: "pending" | "success" | "failed"; + createdAt: Date; + error?: string; + } +): Promise { + // TODO: Store deployment history + // For now, just log + console.log("Deployment record:", record); +} + +/** + * Check DNS configuration for a domain (simplified for now) + */ +export async function checkDnsConfiguration(domain: string): Promise { + // TODO: Implement actual DNS checking + console.log(`DNS check requested for domain: ${domain}`); + + // For now, return true for plasmic.run domains + if (domain.endsWith(".plasmic.run")) { + return true; + } + + return false; +} diff --git a/platform/wab/src/wab/server/routes/custom-routes.ts b/platform/wab/src/wab/server/routes/custom-routes.ts index 1b84618c0..e7a0b69da 100644 --- a/platform/wab/src/wab/server/routes/custom-routes.ts +++ b/platform/wab/src/wab/server/routes/custom-routes.ts @@ -4,6 +4,7 @@ import { RevalidatePlasmicHostingResponse, } from "@/wab/shared/ApiSchema"; import { Application, Request, Response } from "express"; +import * as hosting from "./hosting"; export const ROUTES_WITH_TIMING = []; @@ -15,7 +16,34 @@ export function addInternalRoutes(app: Application) { export function addInternalIntegrationsRoutes(app: Application) {} function addHostingRoutes(app: Application) { - app.post("/api/v1/revalidate-hosting", withNext(revalidatePlasmicHosting)); + // New hosting endpoints + // Domain management + app.get("/api/v1/check-domain", withNext(hosting.checkDomain)); + app.get( + "/api/v1/domains-for-project/:projectId", + withNext(hosting.getDomainsForProject) + ); + app.put( + "/api/v1/subdomain-for-project", + withNext(hosting.setSubdomainForProject) + ); + app.put( + "/api/v1/custom-domain-for-project", + withNext(hosting.setCustomDomainForProject) + ); + + // Hosting settings + app.get( + "/api/v1/plasmic-hosting/:projectId", + withNext(hosting.getPlasmicHostingSettings) + ); + app.put( + "/api/v1/plasmic-hosting/:projectId", + withNext(hosting.updatePlasmicHostingSettings) + ); + + // Use our new implementation for revalidate + app.post("/api/v1/revalidate-hosting", withNext(hosting.revalidateHosting)); } function addPaymentRoutes(app: Application) { diff --git a/platform/wab/src/wab/server/routes/hosting.ts b/platform/wab/src/wab/server/routes/hosting.ts new file mode 100644 index 000000000..bd1359b00 --- /dev/null +++ b/platform/wab/src/wab/server/routes/hosting.ts @@ -0,0 +1,471 @@ +import { DbMgr } from "@/wab/server/db/DbMgr"; +import { + checkDnsConfiguration, + deployProjectToHosting, +} from "@/wab/server/hosting/deploy"; +import { userDbMgr } from "@/wab/server/routes/util"; +import { + CheckDomainResponse, + CheckDomainStatus, + DomainsForProjectResponse, + PlasmicHostingSettings, + ProjectId, + RevalidatePlasmicHostingRequest, + RevalidatePlasmicHostingResponse, + SetCustomDomainForProjectResponse, + SetDomainStatus, + SetSubdomainForProjectResponse, +} from "@/wab/shared/ApiSchema"; +import { DEVFLAGS } from "@/wab/shared/devflags"; +import { PLASMIC_HOSTING_DOMAIN_VALIDATOR } from "@/wab/shared/hosting"; +import { Request, Response } from "express"; +import { logger } from "../observability"; + +// Constants for key-value storage (placeholders for future implementation) +// const HOSTING_SUBDOMAIN_KEY = "hosting.subdomain"; +// const HOSTING_CUSTOM_DOMAINS_KEY = "hosting.customDomains"; +// const HOSTING_SETTINGS_KEY = "hosting.settings"; + +// Helper function to separate subdomain from custom domains +function separateDomainsForProject( + domains: string[], + subdomainSuffix: string +): { + subdomain?: string; + customDomains: string[]; +} { + const subdomain = domains.find((d) => d.endsWith(subdomainSuffix)); + const customDomains = domains.filter((d) => !d.endsWith(subdomainSuffix)); + return { subdomain, customDomains }; +} + +// Helper to get subdomain suffix +function getSubdomainSuffix(): string { + return DEVFLAGS.plasmicHostingSubdomainSuffix; +} + +// GET /api/v1/check-domain +export async function checkDomain(req: Request, res: Response): Promise { + let { domain } = req.query as { domain: string }; + + if (!domain) { + res.status(400).json({ error: "Domain parameter required" }); + return; + } + + // Strip quotes if they were included in the domain + domain = domain.replace(/^["']|["']$/g, ""); + + const mgr = userDbMgr(req); + const subdomainSuffix = getSubdomainSuffix(); + const validator = PLASMIC_HOSTING_DOMAIN_VALIDATOR; + + try { + // Validate domain format + if (!validator.isValidDomainOrSubdomain(domain)) { + const status: CheckDomainStatus = { + isValid: false, + }; + res.json({ status } satisfies CheckDomainResponse); + return; + } + + // Check if domain is already used + const projectsUsingDomain = await getProjectsUsingDomain(mgr, domain); + const configuredBy = + projectsUsingDomain.length > 0 + ? `project:${projectsUsingDomain[0].id}` + : undefined; + + // For now, simulate DNS configuration check + // TODO: Implement actual DNS checking + const isCorrectlyConfigured = await checkDnsConfiguration(domain); + + const status: CheckDomainStatus = { + isValid: true, + isAvailable: projectsUsingDomain.length === 0, + isPlasmicSubdomain: domain.endsWith(subdomainSuffix), + isAnyPlasmicDomain: validator.isAnyPlasmicDomain(domain), + isCorrectlyConfigured, + configuredBy, + }; + res.json({ status } satisfies CheckDomainResponse); + } catch (error) { + console.error("Domain check error:", error); + res.status(500).json({ error: "Internal server error" }); + } +} + +// Helper function to find projects using a domain +async function getProjectsUsingDomain(mgr: DbMgr, domain: string) { + // Use the built-in method that already exists in DbMgr + try { + const projectId = await mgr.tryGetProjectIdForDomain(domain); + if (projectId) { + const project = await mgr.getProjectById(projectId); + return [project]; + } + return []; + } catch (e) { + // Project might have been deleted or other error + return []; + } +} + +// GET /api/v1/domains-for-project/:projectId +export async function getDomainsForProject( + req: Request, + res: Response +): Promise { + const { projectId } = req.params; + const mgr = userDbMgr(req); + + try { + // Check permissions + await mgr.checkProjectPerms( + projectId as ProjectId, + "viewer", + "get domains" + ); + + // Get domains using the existing DbMgr method + const domains = await mgr.getDomainsForProject(projectId as ProjectId); + + res.json({ domains } satisfies DomainsForProjectResponse); + } catch (error: any) { + console.error("Get domains error:", error); + if (error.message?.includes("permission")) { + res.status(403).json({ error: "Access denied" }); + } else { + res.status(500).json({ error: "Failed to get domains" }); + } + } +} + +// PUT /api/v1/subdomain-for-project +export async function setSubdomainForProject( + req: Request, + res: Response +): Promise { + const { subdomain, projectId } = req.body; + const mgr = userDbMgr(req); + + try { + // Check permissions + await mgr.checkProjectPerms( + projectId as ProjectId, + "editor", + "set subdomain" + ); + + const subdomainSuffix = getSubdomainSuffix(); + + if (subdomain) { + // Check if the subdomain has the correct suffix + if (!subdomain.endsWith(subdomainSuffix)) { + res.json({ + status: "DomainInvalid", + } satisfies SetSubdomainForProjectResponse); + return; + } + + // Check if subdomain is already used by another project + const existingProjects = await getProjectsUsingDomain(mgr, subdomain); + if (existingProjects.length > 0 && existingProjects[0].id !== projectId) { + res.json({ + status: "DomainUsedElsewhereInPlasmic", + } satisfies SetSubdomainForProjectResponse); + return; + } + } + + // Get current domains and update + const currentDomains = await mgr.getDomainsForProject( + projectId as ProjectId + ); + const { customDomains } = separateDomainsForProject( + currentDomains, + subdomainSuffix + ); + + // Build new domain list + const newDomains = subdomain + ? [subdomain, ...customDomains] + : customDomains; + + // Update all domains at once + await mgr.setDomainsForProject(newDomains, projectId as ProjectId); + + res.json({ + status: "DomainUpdated" as SetDomainStatus, + } satisfies SetSubdomainForProjectResponse); + } catch (error: any) { + console.error("Set subdomain error:", error); + if (error.message?.includes("permission")) { + res.status(403).json({ error: "Access denied" }); + } else { + res.status(500).json({ error: "Failed to set subdomain" }); + } + } +} + +// PUT /api/v1/custom-domain-for-project +export async function setCustomDomainForProject( + req: Request, + res: Response +): Promise { + const { customDomain, projectId } = req.body; + const mgr = userDbMgr(req); + + try { + // Check permissions + await mgr.checkProjectPerms( + projectId as ProjectId, + "editor", + "set custom domain" + ); + + // Get current domains + const currentDomains = await mgr.getDomainsForProject( + projectId as ProjectId + ); + const subdomainSuffix = getSubdomainSuffix(); + const { subdomain, customDomains: existingCustomDomains } = + separateDomainsForProject(currentDomains, subdomainSuffix); + + let status: Record = {}; + let newCustomDomains = [...existingCustomDomains]; + + if (customDomain) { + // Validate domain + const validator = PLASMIC_HOSTING_DOMAIN_VALIDATOR; + + // Basic validation + if (!validator.isValidDomain(customDomain)) { + status[customDomain] = "DomainInvalid"; + res.json({ status } satisfies SetCustomDomainForProjectResponse); + return; + } + + // Check if it's accidentally a subdomain + if (customDomain.endsWith(subdomainSuffix)) { + status[customDomain] = "DomainInvalid"; + res.json({ status } satisfies SetCustomDomainForProjectResponse); + return; + } + + // Check if already used elsewhere + const existingProjects = await getProjectsUsingDomain(mgr, customDomain); + if (existingProjects.length > 0 && existingProjects[0].id !== projectId) { + status[customDomain] = "DomainUsedElsewhereInPlasmic"; + res.json({ status } satisfies SetCustomDomainForProjectResponse); + return; + } + + // Add to domains list if not already there + if (!newCustomDomains.includes(customDomain)) { + newCustomDomains.push(customDomain); + } + + // Handle www subdomain automatically + const isApexDomain = !customDomain.startsWith("www."); + const wwwDomain = isApexDomain + ? `www.${customDomain}` + : customDomain.replace("www.", ""); + + // Add both apex and www domains + if (isApexDomain && !newCustomDomains.includes(wwwDomain)) { + newCustomDomains.push(wwwDomain); + status[wwwDomain] = "DomainUpdated"; + } + + status[customDomain] = "DomainUpdated"; + } else { + // Remove all custom domains if customDomain is null/undefined + newCustomDomains = []; + } + + // Build final domain list + const finalDomains = subdomain + ? [subdomain, ...newCustomDomains] + : newCustomDomains; + + // Update domains + await mgr.setDomainsForProject(finalDomains, projectId as ProjectId); + + res.json({ status } satisfies SetCustomDomainForProjectResponse); + } catch (error: any) { + console.error("Set custom domain error:", error); + if (error.message?.includes("permission")) { + res.status(403).json({ error: "Access denied" }); + } else { + res.status(500).json({ error: "Failed to set custom domain" }); + } + } +} + +export async function getPlasmicHostingSettings( + req: Request, + res: Response +): Promise { + const { projectId } = req.params; + const mgr = userDbMgr(req); + + try { + // Check permissions + await mgr.checkProjectPerms( + projectId as ProjectId, + "viewer", + "get hosting settings" + ); + + // Get settings from project metadata or database + // For now, return empty settings as we don't have a KV store implemented + // TODO: Implement proper settings storage + const settings = null; + + if (!settings) { + // Return empty settings if none exist + res.json({} satisfies PlasmicHostingSettings); + return; + } + + // Parse and return settings + const parsedSettings = JSON.parse(settings) as PlasmicHostingSettings; + res.json(parsedSettings); + } catch (error: any) { + console.error("Get hosting settings error:", error); + if (error.message?.includes("permission")) { + res.status(403).json({ error: "Access denied" }); + } else { + res.status(500).json({ error: "Failed to get hosting settings" }); + } + } +} + +export async function updatePlasmicHostingSettings( + req: Request, + res: Response +): Promise { + const { projectId } = req.params; + const settings = req.body as PlasmicHostingSettings; + const mgr = userDbMgr(req); + + try { + // Check permissions + await mgr.checkProjectPerms( + projectId as ProjectId, + "editor", + "update hosting settings" + ); + + // Validate settings + if (settings.favicon) { + if (!settings.favicon.url) { + res.status(400).json({ error: "Favicon URL is required" }); + return; + } + + // Basic URL validation + try { + new URL(settings.favicon.url); + } catch (e) { + res.status(400).json({ error: "Invalid favicon URL" }); + return; + } + } + + // Save settings to project metadata or database + // TODO: Implement proper settings storage + // For now, just log the settings + console.log( + `Would save hosting settings for project ${projectId}:`, + settings + ); + + res.json(settings); + } catch (error: any) { + console.error("Update hosting settings error:", error); + if (error.message?.includes("permission")) { + res.status(403).json({ error: "Access denied" }); + } else { + res.status(500).json({ error: "Failed to update hosting settings" }); + } + } +} + +export async function revalidateHosting( + req: Request, + res: Response +): Promise { + const { projectId } = req.body as RevalidatePlasmicHostingRequest; + const mgr = userDbMgr(req); + + try { + // Check permissions + await mgr.checkProjectPerms( + projectId as ProjectId, + "editor", + "revalidate hosting" + ); + + // Get all domains for the project + const domains = await mgr.getDomainsForProject(projectId as ProjectId); + + logger().info(`Revalidating domains: ${domains}`); + logger().info(`Project ID: ${projectId}`); + + if (domains.length === 0) { + res.json({ + successes: [], + failures: [], + } satisfies RevalidatePlasmicHostingResponse); + return; + } + + // Trigger real deployment + try { + const deploymentResult = await deployProjectToHosting( + mgr, + projectId, + domains + ); + + // Convert deployment results to expected format + const successes = deploymentResult.successfulDomains; + const failures = deploymentResult.failedDomains.map((f) => ({ + domain: f.domain, + error: { + type: "Unknown error" as const, + message: f.error?.message || "Deployment failed", + }, + })); + + res.json({ + successes, + failures, + } satisfies RevalidatePlasmicHostingResponse); + } catch (deployError: any) { + // If the entire deployment fails, mark all domains as failed + const failures = domains.map((domain) => ({ + domain, + error: { + type: "Unknown error" as const, + message: deployError.message || "Deployment failed", + }, + })); + + res.json({ + successes: [], + failures, + } satisfies RevalidatePlasmicHostingResponse); + } + } catch (error: any) { + console.error("Revalidate hosting error:", error); + if (error.message?.includes("permission")) { + res.status(403).json({ error: "Access denied" }); + } else { + res.status(500).json({ error: "Failed to revalidate hosting" }); + } + } +}