From d2b65b4602d90da5e15f73794f873b75ffc399d2 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Tue, 20 Jan 2026 19:29:24 +0000 Subject: [PATCH 01/15] feat: add unpublish functionality for domains - Add unpublishBuild function that removes domain from deployment or deletes build if no domains remain - Add unpublish tRPC procedure in domain router - Add Unpublish button to wstd domain section with info icon tooltip - Add Unpublish button to custom domains section - Disable domain renaming when project is published - Make loadDevBuildByProjectId resilient to multiple dev builds --- .../app/builder/features/topbar/domains.tsx | 30 ++++++ .../app/builder/features/topbar/publish.tsx | 52 +++++++++- packages/domain/src/trpc/domain.ts | 43 ++++++++- packages/project-build/src/db/build.ts | 94 ++++++++++++++++++- packages/project/src/db/project.ts | 17 ++++ .../trpc-interface/src/shared/deployment.ts | 16 ++++ 6 files changed, 242 insertions(+), 10 deletions(-) diff --git a/apps/builder/app/builder/features/topbar/domains.tsx b/apps/builder/app/builder/features/topbar/domains.tsx index 210abb88c7f4..8a3805f8558b 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.error); + return; + } + + await refresh(); + toast.success(`${projectDomain.domain} unpublished`); + }; + const handleRemoveDomain = async () => { setIsRemoveInProgress(true); const result = await nativeClient.domain.remove.mutate({ @@ -361,6 +380,17 @@ const DomainItem = ({ )} + {projectDomain.latestBuildVirtual?.deployment && ( + + )} + + + )} ); diff --git a/packages/domain/src/trpc/domain.ts b/packages/domain/src/trpc/domain.ts index 969c4a1a4ead..0e0100304346 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,44 @@ 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 { + // Remove domain from deployment in DB first + await unpublishBuild( + { projectId: input.projectId, domain: input.domain }, + ctx + ); + + const { deploymentTrpc, env } = ctx.deployment; + + // Call deployment service to delete the worker for this domain + const result = await deploymentTrpc.unpublish.mutate({ + projectId: input.projectId, + domain: input.domain, + // preview support + branchName: env.GITHUB_REF_NAME, + }); + + // Allow NOT_IMPLEMENTED to proceed (for local dev without deployment service) + if (result.success === false && result.error !== "NOT_IMPLEMENTED") { + return result; + } + + return { success: true } as const; + } catch (error) { + return createErrorResponse(error); + } + }), /** * Update *.wstd.* domain */ diff --git a/packages/project-build/src/db/build.ts b/packages/project-build/src/db/build.ts index 6a156e337c1e..c983ce67e355 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,90 @@ 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 the build that has this domain in its 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 the build with this specific domain in deployment.domains + const targetBuild = buildsResult.data.find((build) => { + const deployment = parseDeployment(build.deployment); + if (deployment === undefined) { + return false; + } + if (deployment.destination === "static") { + return false; + } + return deployment.domains.includes(props.domain); + }); + + if (targetBuild === undefined) { + throw new Error(`Domain ${props.domain} is not published`); + } + + const deployment = parseDeployment(targetBuild.deployment); + + if (deployment === undefined || deployment.destination !== "saas") { + throw new Error("Build is not published to SaaS"); + } + + // 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..3a1396116ee2 100644 --- a/packages/project/src/db/project.ts +++ b/packages/project/src/db/project.ts @@ -269,6 +269,23 @@ export const updateDomain = async ( await assertEditPermission(input.id, context); + // Check if project has been published - forbid renaming wstd domain after publishing + const projectData = await context.postgrest.client + .from("DashboardProject") + .select("isPublished") + .eq("id", input.id) + .single(); + + if (projectData.error) { + throw projectData.error; + } + + if (projectData.data.isPublished) { + throw new Error( + "Cannot change domain after the project has been published" + ); + } + const updatedProject = await context.postgrest.client .from("Project") .update({ domain }) diff --git a/packages/trpc-interface/src/shared/deployment.ts b/packages/trpc-interface/src/shared/deployment.ts index db4de930e923..68e245539976 100644 --- a/packages/trpc-interface/src/shared/deployment.ts +++ b/packages/trpc-interface/src/shared/deployment.ts @@ -15,6 +15,13 @@ export const PublishInput = z.object({ logProjectName: z.string(), }); +export const UnpublishInput = z.object({ + projectId: z.string(), + domain: z.string(), + // preview support + branchName: z.string(), +}); + export const Output = z.discriminatedUnion("success", [ z.object({ success: z.literal(true), @@ -39,4 +46,13 @@ export const deploymentRouter = router({ error: "NOT_IMPLEMENTED", }; }), + unpublish: procedure + .input(UnpublishInput) + .output(Output) + .mutation(() => { + return { + success: false, + error: "NOT_IMPLEMENTED", + }; + }), }); From 081761436e385119278e35e209bbb96e89c297cc Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Tue, 20 Jan 2026 19:35:24 +0000 Subject: [PATCH 02/15] cr --- .../app/builder/features/topbar/publish.tsx | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/apps/builder/app/builder/features/topbar/publish.tsx b/apps/builder/app/builder/features/topbar/publish.tsx index 64cdaa6e2fcc..e8f10ae8c2c4 100644 --- a/apps/builder/app/builder/features/topbar/publish.tsx +++ b/apps/builder/app/builder/features/topbar/publish.tsx @@ -110,7 +110,7 @@ const ChangeProjectDomain = ({ const [domain, setDomain] = useState(project.domain); const [error, setError] = useState(); const [isUpdateInProgress, setIsUpdateInProgress] = useOptimistic(false); - const [isUnpublishing, setIsUnpublishing] = useState(false); + const [isUnpublishing, setIsUnpublishing] = useOptimistic(false); const pageUrl = new URL(publishedOrigin); pageUrl.pathname = selectedPagePath; @@ -155,20 +155,16 @@ const ChangeProjectDomain = ({ const handleUnpublish = async () => { setIsUnpublishing(true); - try { - const result = await nativeClient.domain.unpublish.mutate({ - projectId: project.id, - domain: project.domain, - }); - if (result.success === false) { - toast.error(result.error); - return; - } - await refresh(); - toast.success("Project unpublished"); - } finally { - setIsUnpublishing(false); + const result = await nativeClient.domain.unpublish.mutate({ + projectId: project.id, + domain: project.domain, + }); + if (result.success === false) { + toast.error(result.error); + return; } + await refresh(); + toast.success("Project unpublished"); }; const { statusText, status } = @@ -314,10 +310,9 @@ const ChangeProjectDomain = ({ {isPublished && (