diff --git a/apps/builder/app/builder/features/topbar/domains.tsx b/apps/builder/app/builder/features/topbar/domains.tsx index 210abb88c7f4..8f04939c5aa0 100644 --- a/apps/builder/app/builder/features/topbar/domains.tsx +++ b/apps/builder/app/builder/features/topbar/domains.tsx @@ -194,6 +194,25 @@ const DomainItem = ({ const [isRemoveInProgress, setIsRemoveInProgress] = useOptimistic(false); + const [isUnpublishInProgress, setIsUnpublishInProgress] = + useOptimistic(false); + + const handleUnpublish = async () => { + setIsUnpublishInProgress(true); + const result = await nativeClient.domain.unpublish.mutate({ + projectId: projectDomain.projectId, + domain: projectDomain.domain, + }); + + if (result.success === false) { + toast.error(result.message); + return; + } + + await refresh(); + toast.success(result.message); + }; + const handleRemoveDomain = async () => { setIsRemoveInProgress(true); const result = await nativeClient.domain.remove.mutate({ @@ -361,6 +380,17 @@ const DomainItem = ({ )} + {projectDomain.latestBuildVirtual && ( + + )} + + + )} ); diff --git a/apps/builder/app/shared/context.server.ts b/apps/builder/app/shared/context.server.ts index 341b6795a046..a63669a6291c 100644 --- a/apps/builder/app/shared/context.server.ts +++ b/apps/builder/app/shared/context.server.ts @@ -137,6 +137,7 @@ const createDeploymentContext = (builderOrigin: string) => { BUILDER_ORIGIN: getRequestOrigin(builderOrigin), GITHUB_REF_NAME: staticEnv.GITHUB_REF_NAME ?? "undefined", GITHUB_SHA: staticEnv.GITHUB_SHA ?? undefined, + PUBLISHER_HOST: env.PUBLISHER_HOST, }, }; diff --git a/packages/domain/src/trpc/domain.ts b/packages/domain/src/trpc/domain.ts index 969c4a1a4ead..96ecaa91aec4 100644 --- a/packages/domain/src/trpc/domain.ts +++ b/packages/domain/src/trpc/domain.ts @@ -1,7 +1,10 @@ import { z } from "zod"; import { nanoid } from "nanoid"; import * as projectApi from "@webstudio-is/project/index.server"; -import { createProductionBuild } from "@webstudio-is/project-build/index.server"; +import { + createProductionBuild, + unpublishBuild, +} from "@webstudio-is/project-build/index.server"; import { router, procedure, @@ -149,6 +152,55 @@ export const domainRouter = router({ return createErrorResponse(error); } }), + /** + * Unpublish a specific domain from the project + */ + unpublish: procedure + .input( + z.object({ + projectId: z.string(), + domain: z.string(), + }) + ) + .mutation(async ({ input, ctx }) => { + try { + const { deploymentTrpc, env } = ctx.deployment; + + // Call deployment service to delete the worker for this domain + const result = await deploymentTrpc.unpublish.mutate({ + domain: input.domain, + }); + + // Extract subdomain for DB lookup (strip publisher host suffix) + // e.g., "myproject.wstd.work" → "myproject", "custom.com" → "custom.com" + const dbDomain = input.domain.replace(`.${env.PUBLISHER_HOST}`, ""); + + // Always unpublish in DB regardless of worker deletion result + await unpublishBuild( + { projectId: input.projectId, domain: dbDomain }, + ctx + ); + + // If worker deletion failed (and not NOT_IMPLEMENTED), return error + if (result.success === false && result.error !== "NOT_IMPLEMENTED") { + return { + success: false, + message: `Failed to unpublish ${input.domain}: ${result.error}`, + }; + } + + return { + success: true, + message: `${input.domain} unpublished`, + }; + } catch (error) { + console.error("Unpublish failed:", error); + return { + success: false, + message: `Failed to unpublish ${input.domain}: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } + }), /** * Update *.wstd.* domain */ diff --git a/packages/project-build/src/db/build.ts b/packages/project-build/src/db/build.ts index 6a156e337c1e..306a471f829f 100644 --- a/packages/project-build/src/db/build.ts +++ b/packages/project-build/src/db/build.ts @@ -138,17 +138,17 @@ export const loadDevBuildByProjectId = async ( .from("Build") .select("*") .eq("projectId", projectId) - .is("deployment", null); + .is("deployment", null) + .order("createdAt", { ascending: false }) + .limit(1); // .single(); Note: Single response is not compressed. Uncomment the following line once the issue is resolved: https://github.com/orgs/supabase/discussions/28757 if (build.error) { throw build.error; } - if (build.data.length !== 1) { - throw new Error( - `Results contain ${build.data.length} row(s) requires 1 row` - ); + if (build.data.length === 0) { + throw new Error("No dev build found"); } return parseCompactBuild(build.data[0]); @@ -229,6 +229,95 @@ export const createBuild = async ( } }; +export const unpublishBuild = async ( + props: { + projectId: Build["projectId"]; + domain: string; + }, + context: AppContext +) => { + const canEdit = await authorizeProject.hasProjectPermit( + { projectId: props.projectId, permit: "edit" }, + context + ); + + if (canEdit === false) { + throw new AuthorizationError( + "You don't have access to unpublish this project" + ); + } + + // Find all builds that have this domain in their deployment + const buildsResult = await context.postgrest.client + .from("Build") + .select("id, deployment") + .eq("projectId", props.projectId) + .not("deployment", "is", null) + .order("createdAt", { ascending: false }); + + if (buildsResult.error) { + throw buildsResult.error; + } + + // Find all builds with this specific domain in deployment.domains + const targetBuilds = buildsResult.data.filter((build) => { + const deployment = parseDeployment(build.deployment); + if (deployment === undefined) { + return false; + } + if (deployment.destination === "static") { + return false; + } + return deployment.domains.includes(props.domain); + }); + + if (targetBuilds.length === 0) { + throw new Error(`Domain ${props.domain} is not published`); + } + + // Process all builds that contain this domain + for (const targetBuild of targetBuilds) { + const deployment = parseDeployment(targetBuild.deployment); + + if (deployment === undefined || deployment.destination !== "saas") { + continue; + } + + // Remove the domain from the deployment + const remainingDomains = deployment.domains.filter( + (d) => d !== props.domain + ); + + if (remainingDomains.length === 0) { + // Delete the production build entirely when no domains remain + // Don't set deployment=null as that would create a duplicate "dev build" + const result = await context.postgrest.client + .from("Build") + .delete() + .eq("id", targetBuild.id); + + if (result.error) { + throw result.error; + } + } else { + // Update with remaining domains + const newDeployment = JSON.stringify({ + ...deployment, + domains: remainingDomains, + }); + + const result = await context.postgrest.client + .from("Build") + .update({ deployment: newDeployment }) + .eq("id", targetBuild.id); + + if (result.error) { + throw result.error; + } + } + } +}; + export const createProductionBuild = async ( props: { projectId: Build["projectId"]; diff --git a/packages/project/src/db/project.ts b/packages/project/src/db/project.ts index 1bd73338422b..f406cd72919b 100644 --- a/packages/project/src/db/project.ts +++ b/packages/project/src/db/project.ts @@ -269,6 +269,49 @@ export const updateDomain = async ( await assertEditPermission(input.id, context); + // Check if the current wstd domain is published - forbid renaming while published + // Get current domain first + const currentProject = await context.postgrest.client + .from("Project") + .select("domain") + .eq("id", input.id) + .single(); + + if (currentProject.error) { + throw currentProject.error; + } + + // Check if any build has this domain in deployment.domains + const buildsWithDomain = await context.postgrest.client + .from("Build") + .select("id, deployment") + .eq("projectId", input.id) + .not("deployment", "is", null); + + if (buildsWithDomain.error) { + throw buildsWithDomain.error; + } + + const isDomainPublished = buildsWithDomain.data.some((build) => { + const deployment = build.deployment as { + destination?: string; + domains?: string[]; + } | null; + if (deployment === null) { + return false; + } + if (deployment.destination === "static") { + return false; + } + return deployment.domains?.includes(currentProject.data.domain) ?? false; + }); + + if (isDomainPublished) { + throw new Error( + "Cannot change domain while it is published. Unpublish first." + ); + } + const updatedProject = await context.postgrest.client .from("Project") .update({ domain }) diff --git a/packages/trpc-interface/src/context/context.server.ts b/packages/trpc-interface/src/context/context.server.ts index 651bf0844c41..6cfbc16f7ea9 100644 --- a/packages/trpc-interface/src/context/context.server.ts +++ b/packages/trpc-interface/src/context/context.server.ts @@ -60,6 +60,7 @@ type DeploymentContext = { BUILDER_ORIGIN: string; GITHUB_REF_NAME: string; GITHUB_SHA: string | undefined; + PUBLISHER_HOST: string; }; }; diff --git a/packages/trpc-interface/src/shared/deployment.ts b/packages/trpc-interface/src/shared/deployment.ts index db4de930e923..9aaa03caaa5c 100644 --- a/packages/trpc-interface/src/shared/deployment.ts +++ b/packages/trpc-interface/src/shared/deployment.ts @@ -15,6 +15,10 @@ export const PublishInput = z.object({ logProjectName: z.string(), }); +export const UnpublishInput = z.object({ + domain: z.string(), +}); + export const Output = z.discriminatedUnion("success", [ z.object({ success: z.literal(true), @@ -39,4 +43,13 @@ export const deploymentRouter = router({ error: "NOT_IMPLEMENTED", }; }), + unpublish: procedure + .input(UnpublishInput) + .output(Output) + .mutation(() => { + return { + success: false, + error: "NOT_IMPLEMENTED", + }; + }), });