mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-29 16:17:39 +09:00
experimental: SSG download support (#3783)
## Description See corresponding [SaaS PR](https://github.com/webstudio-is/webstudio-saas/pull/311) too ## Steps for reproduction Click Publish/Export <img width="363" alt="image" src="https://github.com/user-attachments/assets/fbc1f4e5-3a2c-4bb9-9242-025196f991bc"> Deploy zip to netlify <img width="1710" alt="image" src="https://github.com/user-attachments/assets/daca4ead-6272-4729-8e82-0986301e5c8b"> ## 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:
1 parent
1f435cc7f9
commit
8be665e68d
38 files changed
+701
-73
No files matched your search
@@ -83,7 +83,8 @@ export const getStatus = (projectDomain: Domain) =>
|
||||
? (`VERIFIED_${projectDomain.domain.status}` as const)
|
||||
: `UNVERIFIED`;
|
||||
|
||||
export const PENDING_TIMEOUT = 60 * 3 * 1000;
|
||||
export const PENDING_TIMEOUT =
|
||||
process.env.NODE_ENV === "production" ? 60 * 3 * 1000 : 35000;
|
||||
|
||||
export const getPublishStatusAndText = ({
|
||||
updatedAt,
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useOptimistic,
|
||||
useTransition,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
Button,
|
||||
@@ -25,6 +33,7 @@ import {
|
||||
Link,
|
||||
PanelBanner,
|
||||
buttonStyle,
|
||||
toast,
|
||||
} from "@webstudio-is/design-system";
|
||||
import stripIndent from "strip-indent";
|
||||
import {
|
||||
@@ -49,7 +58,8 @@ import {
|
||||
} from "@webstudio-is/icons";
|
||||
import { AddDomain } from "./add-domain";
|
||||
import { humanizeString } from "~/shared/string-utils";
|
||||
import { trpcClient } from "~/shared/trpc/trpc-client";
|
||||
import { trpcClient, nativeClient } from "~/shared/trpc/trpc-client";
|
||||
import { isFeatureEnabled } from "@webstudio-is/feature-flags";
|
||||
|
||||
type ProjectData =
|
||||
| {
|
||||
@@ -326,6 +336,7 @@ const Publish = ({
|
||||
domains: domainsToPublish.map(
|
||||
(projectDomain) => projectDomain.domain.domain
|
||||
),
|
||||
destination: "saas",
|
||||
},
|
||||
() => {
|
||||
refresh();
|
||||
@@ -340,6 +351,148 @@ const Publish = ({
|
||||
);
|
||||
};
|
||||
|
||||
const fetchProjectDataStatus = async (projectId: Project["id"]) => {
|
||||
const projectData = await nativeClient.domain.project.query({
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (projectData.success === false) {
|
||||
throw new Error(projectData.error);
|
||||
}
|
||||
|
||||
if (projectData.project.latestStaticBuild == null) {
|
||||
return {
|
||||
status: "INITIAL" as const,
|
||||
statusText: "Not published",
|
||||
};
|
||||
}
|
||||
|
||||
const { status, statusText } = getPublishStatusAndText(
|
||||
projectData.project.latestStaticBuild
|
||||
);
|
||||
|
||||
return { status, statusText };
|
||||
};
|
||||
|
||||
type StaticProjectStatus = Awaited<ReturnType<typeof fetchProjectDataStatus>>;
|
||||
|
||||
const PublishStatic = ({ projectId }: { projectId: Project["id"] }) => {
|
||||
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",
|
||||
});
|
||||
|
||||
const [optimisticPendingStatus, setOptimisticPendingStatus] = useOptimistic(
|
||||
projectDataStatus,
|
||||
(_currentState, optimisticValue: StaticProjectStatus) => {
|
||||
return optimisticValue;
|
||||
}
|
||||
);
|
||||
|
||||
const isPublishInProgress = optimisticPendingStatus.status === "PENDING";
|
||||
|
||||
return (
|
||||
<Flex
|
||||
css={{
|
||||
paddingBottom: theme.spacing[5],
|
||||
paddingTop: theme.spacing[5],
|
||||
}}
|
||||
gap={2}
|
||||
shrink={false}
|
||||
direction={"column"}
|
||||
>
|
||||
{optimisticPendingStatus.status === "FAILED" && (
|
||||
<Text color="destructive">{optimisticPendingStatus.statusText}</Text>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
content={isPublishInProgress ? "Preparing static site" : undefined}
|
||||
>
|
||||
<Button
|
||||
color="positive"
|
||||
state={isPublishInProgress ? "pending" : undefined}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
setOptimisticPendingStatus({
|
||||
status: "PENDING",
|
||||
statusText: "Publishing",
|
||||
});
|
||||
|
||||
const result = await nativeClient.domain.publish.mutate({
|
||||
projectId,
|
||||
destination: "static",
|
||||
});
|
||||
|
||||
if (result.success === false) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = "name" in result ? result.name : undefined;
|
||||
|
||||
if (name == null) {
|
||||
toast.error('File name must be defined in "result"');
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = 10000;
|
||||
|
||||
// Repeat few more times than timeout
|
||||
const repeat = PENDING_TIMEOUT / timeout + 5;
|
||||
|
||||
let projectDataStatus: StaticProjectStatus | undefined;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (projectDataStatus == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProjectDataStatus(projectDataStatus);
|
||||
if (projectDataStatus.status === "PUBLISHED") {
|
||||
window.location.href = `/cgi/static/ssg/${name}`;
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Unknown error");
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
Build and download static site
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorText = ({ children }: { children: string }) => (
|
||||
<Flex
|
||||
css={{
|
||||
@@ -372,6 +525,7 @@ const Content = (props: {
|
||||
onExportClick: () => void;
|
||||
}) => {
|
||||
const [newDomains, setNewDomains] = useState(new Set<string>());
|
||||
|
||||
const {
|
||||
data: domainsResult,
|
||||
load: domainRefresh,
|
||||
@@ -487,11 +641,9 @@ const Content = (props: {
|
||||
<ErrorText>{domainsResult.error}</ErrorText>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<Flex direction="column" justify="end" css={{ height: 0 }}>
|
||||
<Separator />
|
||||
</Flex>
|
||||
|
||||
<AddDomain
|
||||
projectId={props.projectId}
|
||||
refreshDomainResult={domainRefresh}
|
||||
@@ -504,8 +656,7 @@ const Content = (props: {
|
||||
isPublishing={isPublishing}
|
||||
onExportClick={props.onExportClick}
|
||||
/>
|
||||
|
||||
{projectData?.success === true ? (
|
||||
{projectData?.success === true && (
|
||||
<Publish
|
||||
project={projectData.project}
|
||||
domainsToPublish={domainsToPublish}
|
||||
@@ -516,7 +667,8 @@ const Content = (props: {
|
||||
isPublishing={isPublishing}
|
||||
setIsPublishing={setIsPublishing}
|
||||
/>
|
||||
) : (
|
||||
)}
|
||||
{projectData?.success !== true && (
|
||||
<Box css={{ height: theme.spacing[8] }} />
|
||||
)}
|
||||
</>
|
||||
@@ -543,7 +695,7 @@ type DeployTargets = keyof typeof deployTargets;
|
||||
const isDeployTargets = (value: string): value is DeployTargets =>
|
||||
Object.keys(deployTargets).includes(value);
|
||||
|
||||
const ExportContent = () => {
|
||||
const ExportContent = (props: { projectId: Project["id"] }) => {
|
||||
const npxCommand = "npx webstudio@latest";
|
||||
const [deployTarget, setDeployTarget] = useState<DeployTargets>("vercel");
|
||||
|
||||
@@ -557,6 +709,23 @@ const ExportContent = () => {
|
||||
}}
|
||||
>
|
||||
<Grid columns={1} gap={1}>
|
||||
{isFeatureEnabled("staticExport") && (
|
||||
<>
|
||||
<PublishStatic projectId={props.projectId} />
|
||||
<Grid
|
||||
gap={2}
|
||||
align={"center"}
|
||||
css={{
|
||||
gridTemplateColumns: `1fr auto 1fr`,
|
||||
}}
|
||||
>
|
||||
<Separator css={{ alignSelf: "unset" }} />
|
||||
<Text color="main">OR</Text>
|
||||
<Separator css={{ alignSelf: "unset" }} />
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text color="main" variant="labelsTitleCase">
|
||||
Step 1
|
||||
</Text>
|
||||
@@ -750,7 +919,7 @@ export const PublishButton = ({ projectId }: PublishProps) => {
|
||||
{dialogContentType === "export" && (
|
||||
<>
|
||||
<FloatingPanelPopoverTitle>Export</FloatingPanelPopoverTitle>
|
||||
<ExportContent />
|
||||
<ExportContent projectId={projectId} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -35,14 +35,17 @@ export const loadProductionCanvasData = async (
|
||||
|
||||
// Check that build deployment domains are still active and verified
|
||||
// for examle: redeploy created few days later
|
||||
const domains = deployment.domains.filter((domain) =>
|
||||
currentProjectDomains.some(
|
||||
(projectDomain) =>
|
||||
projectDomain.domain.domain === domain &&
|
||||
projectDomain.domain.status === "ACTIVE" &&
|
||||
projectDomain.verified
|
||||
)
|
||||
);
|
||||
const domains =
|
||||
deployment.destination === "static"
|
||||
? []
|
||||
: deployment.domains.filter((domain) =>
|
||||
currentProjectDomains.some(
|
||||
(projectDomain) =>
|
||||
projectDomain.domain.domain === domain &&
|
||||
projectDomain.domain.status === "ACTIVE" &&
|
||||
projectDomain.verified
|
||||
)
|
||||
);
|
||||
|
||||
const page = findPageByIdOrPath("/", build.pages);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createRecursiveProxy } from "@trpc/server/shared";
|
||||
import { useMemo, useState } from "react";
|
||||
import { $authToken } from "../nano-states";
|
||||
|
||||
const client = createTRPCProxyClient<AppRouter>({
|
||||
export const nativeClient = createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: "/trpc",
|
||||
@@ -99,9 +99,9 @@ export const trpcClient: {
|
||||
// await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
// }
|
||||
|
||||
const result = await (client as unknown as Client)[namespace][method][
|
||||
hook === "useMutation" ? "mutate" : "query"
|
||||
](input);
|
||||
const result = await (nativeClient as unknown as Client)[namespace][
|
||||
method
|
||||
][hook === "useMutation" ? "mutate" : "query"](input);
|
||||
|
||||
// Newer request has been made, ignore this one
|
||||
// In the future to support optimistic updates we can call onComplete with the requestId order
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"include": ["app", "**/*.ts", "**/*.tsx", "../../@types/**/*.d.ts"],
|
||||
|
||||
"compilerOptions": {
|
||||
"types": ["@remix-run/node", "vite/client"],
|
||||
"types": ["@remix-run/node", "vite/client", "react/canary"],
|
||||
"paths": {
|
||||
"~/*": ["app/*"]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user