experimental: Download ssg (#3846)

## Description

Can be broken as saas has other non rebased staging PRs

Additionally:
- [x] - Download will start even if dialog is closed

## Steps for reproduction

<img width="371" alt="image"
src="https://github.com/user-attachments/assets/fffc2062-b7b0-4ae9-b8dd-d6acde6bd6fb">

- [x] - Check that vanilla images has usual path
- [x] - Check that vercel has vercel.json at root and specific images
paths
- [x] - Check that netlify has specific images paths


## 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:
5de6)
- [ ] 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
This commit is contained in:
Ivan Starkov authored and GitHub committed 2024-08-01 16:12:36 +03:00
1 parent 0b72f64b80
commit 2096789e91
1 file changed
+110 -48
@@ -5,7 +5,6 @@ import {
useState,
useOptimistic,
useTransition,
useRef,
} from "react";
import { useStore } from "@nanostores/react";
import {
@@ -60,6 +59,8 @@ import { AddDomain } from "./add-domain";
import { humanizeString } from "~/shared/string-utils";
import { trpcClient, nativeClient } from "~/shared/trpc/trpc-client";
import { isFeatureEnabled } from "@webstudio-is/feature-flags";
import type { Templates } from "@webstudio-is/sdk";
import { formatDistance } from "date-fns/formatDistance";
type ProjectData =
| {
@@ -351,6 +352,37 @@ const Publish = ({
);
};
const getStaticPublishStatusAndText = ({
updatedAt,
publishStatus,
}: Pick<NonNullable<Domain["latestBuid"]>, "updatedAt" | "publishStatus">) => {
let status = publishStatus;
const delta = Date.now() - new Date(updatedAt).getTime();
// Assume build failed after 3 minutes
if (publishStatus === "PENDING" && delta > PENDING_TIMEOUT) {
status = "FAILED";
}
const textStart =
status === "PUBLISHED"
? "Downloaded"
: status === "FAILED"
? "Download failed"
: "Download started";
const statusText = `${textStart} ${formatDistance(
new Date(updatedAt),
new Date(),
{
addSuffix: true,
}
)}`;
return { statusText, status };
};
const fetchProjectDataStatus = async (projectId: Project["id"]) => {
const projectData = await nativeClient.domain.project.query({
projectId,
@@ -362,37 +394,51 @@ const fetchProjectDataStatus = async (projectId: Project["id"]) => {
if (projectData.project.latestStaticBuild == null) {
return {
status: "INITIAL" as const,
status: "LOADED" as const,
statusText: "Not published",
};
}
const { status, statusText } = getPublishStatusAndText(
const { status, statusText } = getStaticPublishStatusAndText(
projectData.project.latestStaticBuild
);
return { status, statusText };
};
type StaticProjectStatus = Awaited<ReturnType<typeof fetchProjectDataStatus>>;
type StaticProjectStatus =
| Awaited<ReturnType<typeof fetchProjectDataStatus>>
| { status: "INITIAL"; statusText: string };
const PublishStatic = ({ projectId }: { projectId: Project["id"] }) => {
const PublishStatic = ({
projectId,
templates,
}: {
projectId: Project["id"];
templates: readonly Templates[];
}) => {
const [_, startTransition] = useTransition();
const isCanceledRef = useRef(false);
useEffect(() => {
isCanceledRef.current = false;
return () => {
isCanceledRef.current = true;
};
}, []);
const [projectDataStatus, setProjectDataStatus] =
useState<StaticProjectStatus>({
status: "INITIAL",
statusText: "Not published",
statusText: "Loading",
});
useEffect(() => {
startTransition(async () => {
try {
const projectDataStatus = await fetchProjectDataStatus(projectId);
setProjectDataStatus(projectDataStatus);
} catch (e) {
setProjectDataStatus({
status: "FAILED",
statusText: e instanceof Error ? e.message : "Unknown error",
});
}
});
}, [projectId]);
const [optimisticPendingStatus, setOptimisticPendingStatus] = useOptimistic(
projectDataStatus,
(_currentState, optimisticValue: StaticProjectStatus) => {
@@ -400,18 +446,12 @@ const PublishStatic = ({ projectId }: { projectId: Project["id"] }) => {
}
);
const isPublishInProgress = optimisticPendingStatus.status === "PENDING";
const isPublishInProgress =
optimisticPendingStatus.status === "PENDING" ||
optimisticPendingStatus.status === "INITIAL";
return (
<Flex
css={{
paddingBottom: theme.spacing[5],
paddingTop: theme.spacing[5],
}}
gap={2}
shrink={false}
direction={"column"}
>
<Flex gap={2} shrink={false} direction={"column"}>
{optimisticPendingStatus.status === "FAILED" && (
<Text color="destructive">{optimisticPendingStatus.statusText}</Text>
)}
@@ -433,7 +473,7 @@ const PublishStatic = ({ projectId }: { projectId: Project["id"] }) => {
const result = await nativeClient.domain.publish.mutate({
projectId,
destination: "static",
templates: ["ssg"],
templates: [...templates],
});
if (result.success === false) {
@@ -458,16 +498,8 @@ const PublishStatic = ({ projectId }: { projectId: Project["id"] }) => {
for (let i = 0; i !== repeat; i++) {
await new Promise((resolve) => setTimeout(resolve, timeout));
if (isCanceledRef.current) {
break;
}
projectDataStatus = await fetchProjectDataStatus(projectId);
if (isCanceledRef.current) {
break;
}
if (projectDataStatus.status !== "PENDING") {
break;
}
@@ -478,6 +510,12 @@ const PublishStatic = ({ projectId }: { projectId: Project["id"] }) => {
}
setProjectDataStatus(projectDataStatus);
if (projectDataStatus.status === "FAILED") {
// Report if Export failed
toast.error(projectDataStatus.statusText);
}
if (projectDataStatus.status === "PUBLISHED") {
window.location.href = `/cgi/static/ssg/${name}`;
}
@@ -682,9 +720,18 @@ const Content = (props: {
};
const deployTargets = {
vanilla: {
command: `
npm install
npm run dev
`,
docs: "https://remix.run/",
ssgTemplates: ["ssg"],
},
vercel: {
command: "npx vercel@latest",
docs: "https://vercel.com/docs/cli",
ssgTemplates: ["ssg-vercel"],
},
netlify: {
command: `
@@ -693,6 +740,7 @@ npx netlify-cli sites:create
npx netlify-cli build
npx netlify-cli deploy`,
docs: "https://docs.netlify.com/cli/get-started/",
ssgTemplates: ["ssg-netlify"],
},
} as const;
@@ -714,10 +762,36 @@ const ExportContent = (props: { projectId: Project["id"] }) => {
marginTop: theme.spacing[5],
}}
>
<Grid columns={1} gap={2}>
<div />
<Grid columns={2} gap={2} align={"center"}>
<Text color="main" variant="labelsTitleCase">
Destination
</Text>
<Select
fullWidth
value={deployTarget}
options={Object.keys(deployTargets)}
getLabel={(value) => humanizeString(value)}
onChange={(value) => {
if (isDeployTargets(value)) {
setDeployTarget(value);
}
}}
/>
</Grid>
</Grid>
<Grid columns={1} gap={1}>
{isFeatureEnabled("staticExport") && (
<>
<PublishStatic projectId={props.projectId} />
<PublishStatic
projectId={props.projectId}
templates={deployTargets[deployTarget].ssgTemplates}
/>
<div />
<div />
<Grid
gap={2}
align={"center"}
@@ -726,7 +800,7 @@ const ExportContent = (props: { projectId: Project["id"] }) => {
}}
>
<Separator css={{ alignSelf: "unset" }} />
<Text color="main">OR</Text>
<Text color="main">CLI</Text>
<Separator css={{ alignSelf: "unset" }} />
</Grid>
</>
@@ -811,18 +885,6 @@ const ExportContent = (props: { projectId: Project["id"] }) => {
</Text>
</Grid>
<Select
fullWidth
value={deployTarget}
options={Object.keys(deployTargets)}
getLabel={(value) => humanizeString(value)}
onChange={(value) => {
if (isDeployTargets(value)) {
setDeployTarget(value);
}
}}
/>
<Flex gap={2} align="end">
<TextArea
css={{ flex: 1 }}
@@ -854,7 +916,7 @@ const ExportContent = (props: { projectId: Project["id"] }) => {
<Link
variant="inherit"
color="inherit"
href="https://github.com/webstudio-is/webstudio/tree/main/packages/cli"
href="https://wstd.us/cli"
target="_blank"
rel="noreferrer"
>