Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/builder/app/builder/features/topbar/domains.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -361,6 +380,17 @@ const DomainItem = ({
</>
)}

{projectDomain.latestBuildVirtual && (
<Button
formAction={handleUnpublish}
state={isUnpublishInProgress ? "pending" : undefined}
color="destructive"
css={{ width: "100%", flexShrink: 0 }}
>
Unpublish
</Button>
)}

<Button
formAction={handleRemoveDomain}
state={isRemoveInProgress ? "pending" : undefined}
Expand Down
48 changes: 44 additions & 4 deletions apps/builder/app/builder/features/topbar/publish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const ChangeProjectDomain = ({
const [domain, setDomain] = useState(project.domain);
const [error, setError] = useState<string>();
const [isUpdateInProgress, setIsUpdateInProgress] = useOptimistic(false);
const [isUnpublishing, setIsUnpublishing] = useOptimistic(false);

const pageUrl = new URL(publishedOrigin);
pageUrl.pathname = selectedPagePath;
Expand Down Expand Up @@ -152,6 +153,20 @@ const ChangeProjectDomain = ({
});
};

const handleUnpublish = async () => {
setIsUnpublishing(true);
const result = await nativeClient.domain.unpublish.mutate({
projectId: project.id,
domain: `${project.domain}.${publisherHost}`,
});
if (result.success === false) {
toast.error(result.message);
return;
}
await refresh();
toast.success(result.message);
};

const { statusText, status } =
project.latestBuildVirtual != null
? getPublishStatusAndText(project.latestBuildVirtual)
Expand All @@ -160,6 +175,9 @@ const ChangeProjectDomain = ({
status: "PENDING" as const,
};

// Check if the wstd domain specifically is published (not just any custom domain)
const isPublished = project.latestBuildVirtual?.domain === project.domain;

return (
<CollapsibleDomainSection
title={pageUrl.host}
Expand Down Expand Up @@ -209,15 +227,25 @@ const ChangeProjectDomain = ({
>
<Grid gap={2}>
<Grid flow="column" align="center" gap={2}>
<Label htmlFor={id} css={{ width: theme.spacing[20] }}>
Domain:
</Label>
<Flex align="center" gap={1} css={{ width: theme.spacing[20] }}>
<Label htmlFor={id}>Domain:</Label>
<Tooltip
content="Domain can't be renamed once published. Unpublish to enable renaming."
variant="wrapped"
>
<InfoCircleIcon
tabIndex={0}
style={{ flexShrink: 0 }}
color={rawTheme.colors.foregroundSubtle}
/>
</Tooltip>
</Flex>
<InputField
text="mono"
id={id}
placeholder="Domain"
value={domain}
disabled={isUpdateInProgress}
disabled={isUpdateInProgress || isPublished}
onChange={(event) => {
setError(undefined);
setDomain(event.target.value);
Expand Down Expand Up @@ -280,6 +308,18 @@ const ChangeProjectDomain = ({
/>
</Grid>
)}
{isPublished && (
<Tooltip content="Unpublish to enable domain renaming">
<Button
formAction={handleUnpublish}
color="destructive"
state={isUnpublishing ? "pending" : undefined}
css={{ width: "100%" }}
>
Unpublish
</Button>
</Tooltip>
)}
</Grid>
</CollapsibleDomainSection>
);
Expand Down
1 change: 1 addition & 0 deletions apps/builder/app/shared/context.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};

Expand Down
54 changes: 53 additions & 1 deletion packages/domain/src/trpc/domain.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
*/
Expand Down
99 changes: 94 additions & 5 deletions packages/project-build/src/db/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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"];
Expand Down
43 changes: 43 additions & 0 deletions packages/project/src/db/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
1 change: 1 addition & 0 deletions packages/trpc-interface/src/context/context.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type DeploymentContext = {
BUILDER_ORIGIN: string;
GITHUB_REF_NAME: string;
GITHUB_SHA: string | undefined;
PUBLISHER_HOST: string;
};
};

Expand Down
Loading