Skip to content

Commit 4f27152

Browse files
authored
feat: Unpublish (#5579)
## Description 1. What is this PR about (link the issue and add a short description) ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
1 parent fdee339 commit 4f27152

File tree

8 files changed

+279
-10
lines changed

8 files changed

+279
-10
lines changed

apps/builder/app/builder/features/topbar/domains.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,25 @@ const DomainItem = ({
194194

195195
const [isRemoveInProgress, setIsRemoveInProgress] = useOptimistic(false);
196196

197+
const [isUnpublishInProgress, setIsUnpublishInProgress] =
198+
useOptimistic(false);
199+
200+
const handleUnpublish = async () => {
201+
setIsUnpublishInProgress(true);
202+
const result = await nativeClient.domain.unpublish.mutate({
203+
projectId: projectDomain.projectId,
204+
domain: projectDomain.domain,
205+
});
206+
207+
if (result.success === false) {
208+
toast.error(result.message);
209+
return;
210+
}
211+
212+
await refresh();
213+
toast.success(result.message);
214+
};
215+
197216
const handleRemoveDomain = async () => {
198217
setIsRemoveInProgress(true);
199218
const result = await nativeClient.domain.remove.mutate({
@@ -361,6 +380,17 @@ const DomainItem = ({
361380
</>
362381
)}
363382

383+
{projectDomain.latestBuildVirtual && (
384+
<Button
385+
formAction={handleUnpublish}
386+
state={isUnpublishInProgress ? "pending" : undefined}
387+
color="destructive"
388+
css={{ width: "100%", flexShrink: 0 }}
389+
>
390+
Unpublish
391+
</Button>
392+
)}
393+
364394
<Button
365395
formAction={handleRemoveDomain}
366396
state={isRemoveInProgress ? "pending" : undefined}

apps/builder/app/builder/features/topbar/publish.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ const ChangeProjectDomain = ({
110110
const [domain, setDomain] = useState(project.domain);
111111
const [error, setError] = useState<string>();
112112
const [isUpdateInProgress, setIsUpdateInProgress] = useOptimistic(false);
113+
const [isUnpublishing, setIsUnpublishing] = useOptimistic(false);
113114

114115
const pageUrl = new URL(publishedOrigin);
115116
pageUrl.pathname = selectedPagePath;
@@ -152,6 +153,20 @@ const ChangeProjectDomain = ({
152153
});
153154
};
154155

156+
const handleUnpublish = async () => {
157+
setIsUnpublishing(true);
158+
const result = await nativeClient.domain.unpublish.mutate({
159+
projectId: project.id,
160+
domain: `${project.domain}.${publisherHost}`,
161+
});
162+
if (result.success === false) {
163+
toast.error(result.message);
164+
return;
165+
}
166+
await refresh();
167+
toast.success(result.message);
168+
};
169+
155170
const { statusText, status } =
156171
project.latestBuildVirtual != null
157172
? getPublishStatusAndText(project.latestBuildVirtual)
@@ -160,6 +175,9 @@ const ChangeProjectDomain = ({
160175
status: "PENDING" as const,
161176
};
162177

178+
// Check if the wstd domain specifically is published (not just any custom domain)
179+
const isPublished = project.latestBuildVirtual?.domain === project.domain;
180+
163181
return (
164182
<CollapsibleDomainSection
165183
title={pageUrl.host}
@@ -209,15 +227,25 @@ const ChangeProjectDomain = ({
209227
>
210228
<Grid gap={2}>
211229
<Grid flow="column" align="center" gap={2}>
212-
<Label htmlFor={id} css={{ width: theme.spacing[20] }}>
213-
Domain:
214-
</Label>
230+
<Flex align="center" gap={1} css={{ width: theme.spacing[20] }}>
231+
<Label htmlFor={id}>Domain:</Label>
232+
<Tooltip
233+
content="Domain can't be renamed once published. Unpublish to enable renaming."
234+
variant="wrapped"
235+
>
236+
<InfoCircleIcon
237+
tabIndex={0}
238+
style={{ flexShrink: 0 }}
239+
color={rawTheme.colors.foregroundSubtle}
240+
/>
241+
</Tooltip>
242+
</Flex>
215243
<InputField
216244
text="mono"
217245
id={id}
218246
placeholder="Domain"
219247
value={domain}
220-
disabled={isUpdateInProgress}
248+
disabled={isUpdateInProgress || isPublished}
221249
onChange={(event) => {
222250
setError(undefined);
223251
setDomain(event.target.value);
@@ -280,6 +308,18 @@ const ChangeProjectDomain = ({
280308
/>
281309
</Grid>
282310
)}
311+
{isPublished && (
312+
<Tooltip content="Unpublish to enable domain renaming">
313+
<Button
314+
formAction={handleUnpublish}
315+
color="destructive"
316+
state={isUnpublishing ? "pending" : undefined}
317+
css={{ width: "100%" }}
318+
>
319+
Unpublish
320+
</Button>
321+
</Tooltip>
322+
)}
283323
</Grid>
284324
</CollapsibleDomainSection>
285325
);

apps/builder/app/shared/context.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ const createDeploymentContext = (builderOrigin: string) => {
137137
BUILDER_ORIGIN: getRequestOrigin(builderOrigin),
138138
GITHUB_REF_NAME: staticEnv.GITHUB_REF_NAME ?? "undefined",
139139
GITHUB_SHA: staticEnv.GITHUB_SHA ?? undefined,
140+
PUBLISHER_HOST: env.PUBLISHER_HOST,
140141
},
141142
};
142143

packages/domain/src/trpc/domain.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { z } from "zod";
22
import { nanoid } from "nanoid";
33
import * as projectApi from "@webstudio-is/project/index.server";
4-
import { createProductionBuild } from "@webstudio-is/project-build/index.server";
4+
import {
5+
createProductionBuild,
6+
unpublishBuild,
7+
} from "@webstudio-is/project-build/index.server";
58
import {
69
router,
710
procedure,
@@ -149,6 +152,55 @@ export const domainRouter = router({
149152
return createErrorResponse(error);
150153
}
151154
}),
155+
/**
156+
* Unpublish a specific domain from the project
157+
*/
158+
unpublish: procedure
159+
.input(
160+
z.object({
161+
projectId: z.string(),
162+
domain: z.string(),
163+
})
164+
)
165+
.mutation(async ({ input, ctx }) => {
166+
try {
167+
const { deploymentTrpc, env } = ctx.deployment;
168+
169+
// Call deployment service to delete the worker for this domain
170+
const result = await deploymentTrpc.unpublish.mutate({
171+
domain: input.domain,
172+
});
173+
174+
// Extract subdomain for DB lookup (strip publisher host suffix)
175+
// e.g., "myproject.wstd.work" → "myproject", "custom.com" → "custom.com"
176+
const dbDomain = input.domain.replace(`.${env.PUBLISHER_HOST}`, "");
177+
178+
// Always unpublish in DB regardless of worker deletion result
179+
await unpublishBuild(
180+
{ projectId: input.projectId, domain: dbDomain },
181+
ctx
182+
);
183+
184+
// If worker deletion failed (and not NOT_IMPLEMENTED), return error
185+
if (result.success === false && result.error !== "NOT_IMPLEMENTED") {
186+
return {
187+
success: false,
188+
message: `Failed to unpublish ${input.domain}: ${result.error}`,
189+
};
190+
}
191+
192+
return {
193+
success: true,
194+
message: `${input.domain} unpublished`,
195+
};
196+
} catch (error) {
197+
console.error("Unpublish failed:", error);
198+
return {
199+
success: false,
200+
message: `Failed to unpublish ${input.domain}: ${error instanceof Error ? error.message : "Unknown error"}`,
201+
};
202+
}
203+
}),
152204
/**
153205
* Update *.wstd.* domain
154206
*/

packages/project-build/src/db/build.ts

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,17 +138,17 @@ export const loadDevBuildByProjectId = async (
138138
.from("Build")
139139
.select("*")
140140
.eq("projectId", projectId)
141-
.is("deployment", null);
141+
.is("deployment", null)
142+
.order("createdAt", { ascending: false })
143+
.limit(1);
142144
// .single(); Note: Single response is not compressed. Uncomment the following line once the issue is resolved: https://github.com/orgs/supabase/discussions/28757
143145

144146
if (build.error) {
145147
throw build.error;
146148
}
147149

148-
if (build.data.length !== 1) {
149-
throw new Error(
150-
`Results contain ${build.data.length} row(s) requires 1 row`
151-
);
150+
if (build.data.length === 0) {
151+
throw new Error("No dev build found");
152152
}
153153

154154
return parseCompactBuild(build.data[0]);
@@ -229,6 +229,95 @@ export const createBuild = async (
229229
}
230230
};
231231

232+
export const unpublishBuild = async (
233+
props: {
234+
projectId: Build["projectId"];
235+
domain: string;
236+
},
237+
context: AppContext
238+
) => {
239+
const canEdit = await authorizeProject.hasProjectPermit(
240+
{ projectId: props.projectId, permit: "edit" },
241+
context
242+
);
243+
244+
if (canEdit === false) {
245+
throw new AuthorizationError(
246+
"You don't have access to unpublish this project"
247+
);
248+
}
249+
250+
// Find all builds that have this domain in their deployment
251+
const buildsResult = await context.postgrest.client
252+
.from("Build")
253+
.select("id, deployment")
254+
.eq("projectId", props.projectId)
255+
.not("deployment", "is", null)
256+
.order("createdAt", { ascending: false });
257+
258+
if (buildsResult.error) {
259+
throw buildsResult.error;
260+
}
261+
262+
// Find all builds with this specific domain in deployment.domains
263+
const targetBuilds = buildsResult.data.filter((build) => {
264+
const deployment = parseDeployment(build.deployment);
265+
if (deployment === undefined) {
266+
return false;
267+
}
268+
if (deployment.destination === "static") {
269+
return false;
270+
}
271+
return deployment.domains.includes(props.domain);
272+
});
273+
274+
if (targetBuilds.length === 0) {
275+
throw new Error(`Domain ${props.domain} is not published`);
276+
}
277+
278+
// Process all builds that contain this domain
279+
for (const targetBuild of targetBuilds) {
280+
const deployment = parseDeployment(targetBuild.deployment);
281+
282+
if (deployment === undefined || deployment.destination !== "saas") {
283+
continue;
284+
}
285+
286+
// Remove the domain from the deployment
287+
const remainingDomains = deployment.domains.filter(
288+
(d) => d !== props.domain
289+
);
290+
291+
if (remainingDomains.length === 0) {
292+
// Delete the production build entirely when no domains remain
293+
// Don't set deployment=null as that would create a duplicate "dev build"
294+
const result = await context.postgrest.client
295+
.from("Build")
296+
.delete()
297+
.eq("id", targetBuild.id);
298+
299+
if (result.error) {
300+
throw result.error;
301+
}
302+
} else {
303+
// Update with remaining domains
304+
const newDeployment = JSON.stringify({
305+
...deployment,
306+
domains: remainingDomains,
307+
});
308+
309+
const result = await context.postgrest.client
310+
.from("Build")
311+
.update({ deployment: newDeployment })
312+
.eq("id", targetBuild.id);
313+
314+
if (result.error) {
315+
throw result.error;
316+
}
317+
}
318+
}
319+
};
320+
232321
export const createProductionBuild = async (
233322
props: {
234323
projectId: Build["projectId"];

packages/project/src/db/project.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,49 @@ export const updateDomain = async (
269269

270270
await assertEditPermission(input.id, context);
271271

272+
// Check if the current wstd domain is published - forbid renaming while published
273+
// Get current domain first
274+
const currentProject = await context.postgrest.client
275+
.from("Project")
276+
.select("domain")
277+
.eq("id", input.id)
278+
.single();
279+
280+
if (currentProject.error) {
281+
throw currentProject.error;
282+
}
283+
284+
// Check if any build has this domain in deployment.domains
285+
const buildsWithDomain = await context.postgrest.client
286+
.from("Build")
287+
.select("id, deployment")
288+
.eq("projectId", input.id)
289+
.not("deployment", "is", null);
290+
291+
if (buildsWithDomain.error) {
292+
throw buildsWithDomain.error;
293+
}
294+
295+
const isDomainPublished = buildsWithDomain.data.some((build) => {
296+
const deployment = build.deployment as {
297+
destination?: string;
298+
domains?: string[];
299+
} | null;
300+
if (deployment === null) {
301+
return false;
302+
}
303+
if (deployment.destination === "static") {
304+
return false;
305+
}
306+
return deployment.domains?.includes(currentProject.data.domain) ?? false;
307+
});
308+
309+
if (isDomainPublished) {
310+
throw new Error(
311+
"Cannot change domain while it is published. Unpublish first."
312+
);
313+
}
314+
272315
const updatedProject = await context.postgrest.client
273316
.from("Project")
274317
.update({ domain })

packages/trpc-interface/src/context/context.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ type DeploymentContext = {
6060
BUILDER_ORIGIN: string;
6161
GITHUB_REF_NAME: string;
6262
GITHUB_SHA: string | undefined;
63+
PUBLISHER_HOST: string;
6364
};
6465
};
6566

0 commit comments

Comments
 (0)