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:
Ivan Starkov authored and GitHub committed 2024-07-30 08:57:33 +03:00
1 parent 1f435cc7f9
commit 8be665e68d
38 files changed
+701 -73

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
name: CLI R2 SSG
on:
push:
branches:
- "*.staging"
# cancel in-progress runs on new commits to same PR (gitub.event.number)
concurrency:
group: vercel-cli-r2-static-${{ github.workflow }}-${{ github.event.number || github.sha }}
cancel-in-progress: true
permissions:
contents: read # to fetch code (actions/checkout)
statuses: write # This is required for the GitHub Script createCommitStatus to work
packages: write
jobs:
build:
env:
COMPATIBILITY_DATE: 2024-04-10
environment:
name: "staging"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }} # HEAD commit instead of merge commit
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: pnpm instal
run: pnpm install --ignore-scripts
- name: pnpm build
run: pnpm --filter 'ssg^...' run build
# Ideally, execute 'pnpm deploy --prod', but @remix-run/dev doesn't support this flag.
# Despite being listed as a dependency, @remix-run/dev does not install the remix cli.
# TODO: Minimize artefact size due to frequent downloads on each publish.
- name: pnpm deploy
run: pnpm --filter 'ssg' deploy "${{ github.workspace }}/../ssg-template"
- name: Make archive
run: |
tar --use-compress-program="zstd -19" -cf ssg-template.tar.zst ssg-template
working-directory: ${{ github.workspace }}/..
- name: Copy artifact
run: |
# For staging
aws s3 cp ssg-template.tar.zst "s3://${ARTEFACT_BUCKET_NAME}/public/ssg-template/${{ github.ref_name }}.tar.zst"
# For production can be cached forever
aws s3 cp \
--metadata-directive REPLACE --cache-control "public,max-age=31536102,immutable" \
ssg-template.tar.zst "s3://${ARTEFACT_BUCKET_NAME}/public/ssg-template/${{ github.sha }}.tar.zst"
working-directory: ${{ github.workspace }}/..
env:
AWS_ENDPOINT_URL_S3: ${{ secrets.AWS_ENDPOINT_URL_S3 }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
ARTEFACT_BUCKET_NAME: ${{ secrets.ARTEFACT_BUCKET_NAME }}
checks:
environment:
name: "staging"
runs-on: ubuntu-latest
needs: build
steps:
- uses: pnpm/action-setup@v3
with:
version: "9"
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Copy atrifact via http
run: curl -o ssg-template.tar.zst ${{ secrets.ARTEFACT_BUCKET_URL }}/public/ssg-template/${{ github.ref_name }}.tar.zst
- name: Extract archive
run: tar --use-compress-program="zstd -d" -xf ssg-template.tar.zst -C .
- name: Webstudio Build
run: pnpm webstudio build --template ssg --template internal --preview
working-directory: ${{ github.workspace }}/ssg-template
- name: Build
run: pnpm build
working-directory: ${{ github.workspace }}/ssg-template
@@ -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} />
</>
)}
+11 -8
View File
@@ -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);
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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/*"]
},
+30 -4
View File
@@ -1,10 +1,10 @@
{
"build": {
"id": "2b8a9d28-3a0e-48c9-9aa2-b26db7926a22",
"id": "a2e8de30-03d5-4514-a3a6-406b3266a3af",
"projectId": "d845c167-ea07-4875-b08d-83e97c09dcce",
"version": 33,
"createdAt": "2024-07-26T13:42:42.816",
"updatedAt": "2024-07-26T13:42:42.816",
"version": 38,
"createdAt": "2024-07-29T12:50:07.515",
"updatedAt": "2024-07-29T12:50:07.515",
"pages": {
"meta": {
"siteName": "",
@@ -25,6 +25,7 @@
"id": "WPPAbLFyJD_02vhjRd8P4",
"name": "Another page",
"title": "\"Another page\"",
"history": ["/another-page"],
"rootInstanceId": "n_VBMr7klpx25buS0NV7R",
"systemDataSourceId": "tdXe9gFf83hSo9BLWU6xl",
"meta": {
@@ -252,6 +253,16 @@
"type": "asset",
"value": "1d8bf4398f643f5333d415091507d778aaed62f28883642636cbed0be156a0ee"
}
],
[
"CAkmmL8-JAgokmeopoFXh",
{
"id": "CAkmmL8-JAgokmeopoFXh",
"instanceId": "2sIE8GxbKRBaav_zdhaZ1",
"name": "src",
"type": "string",
"value": "https://picsum.photos/id/237/100/100.jpg?blur=4&grayscale"
}
]
],
"dataSources": [
@@ -298,6 +309,10 @@
{
"type": "id",
"value": "uHB3Fjb7-NELG-bnH7bXB"
},
{
"type": "id",
"value": "2sIE8GxbKRBaav_zdhaZ1"
}
]
}
@@ -308,6 +323,7 @@
"type": "instance",
"id": "MYDt0guk1-vzc7yzqyN6A",
"component": "Heading",
"label": "xD",
"children": [
{
"type": "text",
@@ -380,6 +396,15 @@
"component": "Image",
"children": []
}
],
[
"2sIE8GxbKRBaav_zdhaZ1",
{
"type": "instance",
"id": "2sIE8GxbKRBaav_zdhaZ1",
"component": "Image",
"children": []
}
]
],
"deployment": {
@@ -411,6 +436,7 @@
"id": "WPPAbLFyJD_02vhjRd8P4",
"name": "Another page",
"title": "\"Another page\"",
"history": ["/another-page"],
"rootInstanceId": "n_VBMr7klpx25buS0NV7R",
"systemDataSourceId": "tdXe9gFf83hSo9BLWU6xl",
"meta": {
+1 -1
View File
@@ -1,6 +1,6 @@
export const sitemap = [
{
path: "/",
lastModified: "2024-07-26",
lastModified: "2024-07-29",
},
];
+4
View File
@@ -41,6 +41,10 @@ const Page = ({}: { system: any }) => {
height={16}
className="w-image c161qeci"
/>
<Image
src={"https://picsum.photos/id/237/100/100.jpg?blur=4&grayscale"}
className="w-image"
/>
</Body>
);
};
+4
View File
@@ -9,5 +9,9 @@ export const imageBaseUrl = "/assets/";
* @type {import("@webstudio-is/image").ImageLoader}
*/
export const imageLoader = ({ src }) => {
if (URL.canParse(src)) {
return src;
}
return imageBaseUrl + src;
};
+1
View File
@@ -26,6 +26,7 @@
"@types/react": "^18.2.70",
"@types/react-dom": "^18.2.25",
"@vitejs/plugin-react": "^4.3.1",
"prettier": "3.3.2",
"typescript": "5.5.2",
"vite": "^5.3.4"
},
@@ -1,10 +1,10 @@
{
"build": {
"id": "2b8a9d28-3a0e-48c9-9aa2-b26db7926a22",
"id": "a2e8de30-03d5-4514-a3a6-406b3266a3af",
"projectId": "d845c167-ea07-4875-b08d-83e97c09dcce",
"version": 33,
"createdAt": "2024-07-26T13:42:42.816",
"updatedAt": "2024-07-26T13:42:42.816",
"version": 38,
"createdAt": "2024-07-29T12:50:07.515",
"updatedAt": "2024-07-29T12:50:07.515",
"pages": {
"meta": {
"siteName": "",
@@ -25,6 +25,7 @@
"id": "WPPAbLFyJD_02vhjRd8P4",
"name": "Another page",
"title": "\"Another page\"",
"history": ["/another-page"],
"rootInstanceId": "n_VBMr7klpx25buS0NV7R",
"systemDataSourceId": "tdXe9gFf83hSo9BLWU6xl",
"meta": {
@@ -252,6 +253,16 @@
"type": "asset",
"value": "1d8bf4398f643f5333d415091507d778aaed62f28883642636cbed0be156a0ee"
}
],
[
"CAkmmL8-JAgokmeopoFXh",
{
"id": "CAkmmL8-JAgokmeopoFXh",
"instanceId": "2sIE8GxbKRBaav_zdhaZ1",
"name": "src",
"type": "string",
"value": "https://picsum.photos/id/237/100/100.jpg?blur=4&grayscale"
}
]
],
"dataSources": [
@@ -298,6 +309,10 @@
{
"type": "id",
"value": "uHB3Fjb7-NELG-bnH7bXB"
},
{
"type": "id",
"value": "2sIE8GxbKRBaav_zdhaZ1"
}
]
}
@@ -308,6 +323,7 @@
"type": "instance",
"id": "MYDt0guk1-vzc7yzqyN6A",
"component": "Heading",
"label": "xD",
"children": [
{
"type": "text",
@@ -380,6 +396,15 @@
"component": "Image",
"children": []
}
],
[
"2sIE8GxbKRBaav_zdhaZ1",
{
"type": "instance",
"id": "2sIE8GxbKRBaav_zdhaZ1",
"component": "Image",
"children": []
}
]
],
"deployment": {
@@ -411,6 +436,7 @@
"id": "WPPAbLFyJD_02vhjRd8P4",
"name": "Another page",
"title": "\"Another page\"",
"history": ["/another-page"],
"rootInstanceId": "n_VBMr7klpx25buS0NV7R",
"systemDataSourceId": "tdXe9gFf83hSo9BLWU6xl",
"meta": {
@@ -1,6 +1,6 @@
export const sitemap = [
{
path: "/",
lastModified: "2024-07-26",
lastModified: "2024-07-29",
},
];
@@ -43,6 +43,10 @@ const Page = ({}: { system: any }) => {
height={16}
className="w-image c161qeci"
/>
<Image
src={"https://picsum.photos/id/237/100/100.jpg?blur=4&grayscale"}
className="w-image"
/>
</Body>
);
};
@@ -1,10 +1,10 @@
{
"build": {
"id": "2b8a9d28-3a0e-48c9-9aa2-b26db7926a22",
"id": "a2e8de30-03d5-4514-a3a6-406b3266a3af",
"projectId": "d845c167-ea07-4875-b08d-83e97c09dcce",
"version": 33,
"createdAt": "2024-07-26T13:42:42.816",
"updatedAt": "2024-07-26T13:42:42.816",
"version": 38,
"createdAt": "2024-07-29T12:50:07.515",
"updatedAt": "2024-07-29T12:50:07.515",
"pages": {
"meta": {
"siteName": "",
@@ -25,6 +25,7 @@
"id": "WPPAbLFyJD_02vhjRd8P4",
"name": "Another page",
"title": "\"Another page\"",
"history": ["/another-page"],
"rootInstanceId": "n_VBMr7klpx25buS0NV7R",
"systemDataSourceId": "tdXe9gFf83hSo9BLWU6xl",
"meta": {
@@ -252,6 +253,16 @@
"type": "asset",
"value": "1d8bf4398f643f5333d415091507d778aaed62f28883642636cbed0be156a0ee"
}
],
[
"CAkmmL8-JAgokmeopoFXh",
{
"id": "CAkmmL8-JAgokmeopoFXh",
"instanceId": "2sIE8GxbKRBaav_zdhaZ1",
"name": "src",
"type": "string",
"value": "https://picsum.photos/id/237/100/100.jpg?blur=4&grayscale"
}
]
],
"dataSources": [
@@ -298,6 +309,10 @@
{
"type": "id",
"value": "uHB3Fjb7-NELG-bnH7bXB"
},
{
"type": "id",
"value": "2sIE8GxbKRBaav_zdhaZ1"
}
]
}
@@ -308,6 +323,7 @@
"type": "instance",
"id": "MYDt0guk1-vzc7yzqyN6A",
"component": "Heading",
"label": "xD",
"children": [
{
"type": "text",
@@ -380,6 +396,15 @@
"component": "Image",
"children": []
}
],
[
"2sIE8GxbKRBaav_zdhaZ1",
{
"type": "instance",
"id": "2sIE8GxbKRBaav_zdhaZ1",
"component": "Image",
"children": []
}
]
],
"deployment": {
@@ -411,6 +436,7 @@
"id": "WPPAbLFyJD_02vhjRd8P4",
"name": "Another page",
"title": "\"Another page\"",
"history": ["/another-page"],
"rootInstanceId": "n_VBMr7klpx25buS0NV7R",
"systemDataSourceId": "tdXe9gFf83hSo9BLWU6xl",
"meta": {
@@ -1,6 +1,6 @@
export const sitemap = [
{
path: "/",
lastModified: "2024-07-26",
lastModified: "2024-07-29",
},
];
@@ -43,6 +43,10 @@ const Page = ({}: { system: any }) => {
height={16}
className="w-image c161qeci"
/>
<Image
src={"https://picsum.photos/id/237/100/100.jpg?blur=4&grayscale"}
className="w-image"
/>
</Body>
);
};
@@ -1,10 +1,10 @@
{
"build": {
"id": "2b8a9d28-3a0e-48c9-9aa2-b26db7926a22",
"id": "a2e8de30-03d5-4514-a3a6-406b3266a3af",
"projectId": "d845c167-ea07-4875-b08d-83e97c09dcce",
"version": 33,
"createdAt": "2024-07-26T13:42:42.816",
"updatedAt": "2024-07-26T13:42:42.816",
"version": 38,
"createdAt": "2024-07-29T12:50:07.515",
"updatedAt": "2024-07-29T12:50:07.515",
"pages": {
"meta": {
"siteName": "",
@@ -25,6 +25,7 @@
"id": "WPPAbLFyJD_02vhjRd8P4",
"name": "Another page",
"title": "\"Another page\"",
"history": ["/another-page"],
"rootInstanceId": "n_VBMr7klpx25buS0NV7R",
"systemDataSourceId": "tdXe9gFf83hSo9BLWU6xl",
"meta": {
@@ -252,6 +253,16 @@
"type": "asset",
"value": "1d8bf4398f643f5333d415091507d778aaed62f28883642636cbed0be156a0ee"
}
],
[
"CAkmmL8-JAgokmeopoFXh",
{
"id": "CAkmmL8-JAgokmeopoFXh",
"instanceId": "2sIE8GxbKRBaav_zdhaZ1",
"name": "src",
"type": "string",
"value": "https://picsum.photos/id/237/100/100.jpg?blur=4&grayscale"
}
]
],
"dataSources": [
@@ -298,6 +309,10 @@
{
"type": "id",
"value": "uHB3Fjb7-NELG-bnH7bXB"
},
{
"type": "id",
"value": "2sIE8GxbKRBaav_zdhaZ1"
}
]
}
@@ -308,6 +323,7 @@
"type": "instance",
"id": "MYDt0guk1-vzc7yzqyN6A",
"component": "Heading",
"label": "xD",
"children": [
{
"type": "text",
@@ -380,6 +396,15 @@
"component": "Image",
"children": []
}
],
[
"2sIE8GxbKRBaav_zdhaZ1",
{
"type": "instance",
"id": "2sIE8GxbKRBaav_zdhaZ1",
"component": "Image",
"children": []
}
]
],
"deployment": {
@@ -411,6 +436,7 @@
"id": "WPPAbLFyJD_02vhjRd8P4",
"name": "Another page",
"title": "\"Another page\"",
"history": ["/another-page"],
"rootInstanceId": "n_VBMr7klpx25buS0NV7R",
"systemDataSourceId": "tdXe9gFf83hSo9BLWU6xl",
"meta": {
@@ -1,6 +1,6 @@
export const sitemap = [
{
path: "/",
lastModified: "2024-07-26",
lastModified: "2024-07-29",
},
];
@@ -43,6 +43,10 @@ const Page = ({}: { system: any }) => {
height={16}
className="w-image c161qeci"
/>
<Image
src={"https://picsum.photos/id/237/100/100.jpg?blur=4&grayscale"}
className="w-image"
/>
</Body>
);
};
+9 -5
View File
@@ -296,11 +296,6 @@ export const prebuild = async (options: {
);
}
const domain = siteData.build.deployment?.projectDomain;
if (domain === undefined) {
throw new Error(`Project domain is missing from the project data`);
}
// collect all possible component metas
const metas = new Map<string, WsComponentMeta>();
const componentSources = new Map<string, string>();
@@ -448,6 +443,15 @@ export const prebuild = async (options: {
const assetsToDownload: Promise<void>[] = [];
const appDomain = options.preview ? "wstd.work" : "wstd.io";
const domain =
siteData.build.deployment?.destination === "static"
? siteData.build.deployment?.assetsDomain
: siteData.build.deployment?.projectDomain;
if (domain === undefined) {
throw new Error(`Project domain is missing from the project data`);
}
const assetBuildUrl = `https://${domain}.${appDomain}/cgi/asset/`;
const imageLoader = createImageLoader({
@@ -9,5 +9,9 @@ export const imageBaseUrl = "/assets/";
* @type {import("@webstudio-is/image").ImageLoader}
*/
export const imageLoader = ({ src }) => {
if (URL.canParse(src)) {
return src;
}
return imageBaseUrl + src;
};
+1
View File
@@ -13,6 +13,7 @@
"@webstudio-is/project": "workspace:*",
"@webstudio-is/project-build": "workspace:*",
"@webstudio-is/trpc-interface": "workspace:*",
"nanoid": "^5.0.1",
"zod": "^3.22.4"
},
"devDependencies": {
+37 -6
View File
@@ -4,6 +4,7 @@ import { db as projectDb } from "@webstudio-is/project/index.server";
import { db } from "../db";
import { createProductionBuild } from "@webstudio-is/project-build/index.server";
import { router, procedure } from "@webstudio-is/trpc-interface/index.server";
import { nanoid } from "nanoid";
export const domainRouter = router({
getEntriToken: procedure.query(async ({ ctx }) => {
@@ -41,35 +42,65 @@ export const domainRouter = router({
}
}),
publish: procedure
.input(z.object({ projectId: z.string(), domains: z.array(z.string()) }))
.input(
z.discriminatedUnion("destination", [
z.object({
projectId: z.string(),
domains: z.array(z.string()),
destination: z.literal("saas"),
}),
z.object({
projectId: z.string(),
destination: z.literal("static"),
}),
])
)
.mutation(async ({ input, ctx }) => {
try {
const project = await projectDb.project.loadById(input.projectId, ctx);
const name = `${project.id}-${nanoid()}.zip`;
const build = await createProductionBuild(
{
projectId: input.projectId,
deployment: {
domains: input.domains,
projectDomain: project.domain,
},
deployment:
input.destination === "saas"
? {
destination: input.destination,
domains: input.domains,
projectDomain: project.domain,
}
: {
destination: input.destination,
name,
assetsDomain: project.domain,
templates: ["ssg"],
},
},
ctx
);
const { deploymentTrpc, env } = ctx.deployment;
const result = deploymentTrpc.publish.mutate({
console.info("input.destination", input.destination);
const result = await deploymentTrpc.publish.mutate({
// used to load build data from the builder see routes/rest.build.$buildId.ts
builderOrigin: env.BUILDER_ORIGIN,
githubSha: env.GITHUB_SHA,
buildId: build.id,
// preview support
branchName: env.GITHUB_REF_NAME,
destination: input.destination,
// action log helper (not used for deployment, but for action logs readablity)
projectDomainName: project.domain,
});
if (input.destination === "static" && result.success) {
return { success: true as const, name };
}
return result;
} catch (error) {
return {
+1
View File
@@ -8,3 +8,4 @@ export const cssVars = false;
export const filters = false;
export const xmlElement = false;
export const transforms = false;
export const staticExport = false;
+24
View File
@@ -679,6 +679,30 @@ export type Database = {
},
];
};
LatestStaticBuildPerProject: {
Row: {
buildId: string | null;
projectId: string | null;
publishStatus: Database["public"]["Enums"]["PublishStatus"] | null;
updatedAt: string | null;
};
Relationships: [
{
foreignKeyName: "Build_projectId_fkey";
columns: ["projectId"];
isOneToOne: false;
referencedRelation: "Project";
referencedColumns: ["id"];
},
{
foreignKeyName: "Build_projectId_fkey";
columns: ["projectId"];
isOneToOne: false;
referencedRelation: "DashboardProject";
referencedColumns: ["id"];
},
];
};
ProjectWithDomain: {
Row: {
cname: string | null;
@@ -0,0 +1,86 @@
CREATE OR REPLACE VIEW "LatestBuildPerProject" AS
WITH lb AS (
SELECT DISTINCT ON ("projectId")
bld.id AS "buildId",
bld."projectId",
bld."updatedAt"
FROM
"Build" bld
WHERE
bld.deployment IS NOT NULL
AND bld.deployment::jsonb ->> 'projectDomain'::text IS NOT NULL
ORDER BY
bld."projectId",
bld."createdAt" DESC,
"buildId"
)
SELECT DISTINCT ON ("projectId", "domain")
bld.id AS "buildId",
bld."projectId",
deployment::jsonb ->> 'projectDomain' AS domain,
coalesce(bld."updatedAt" = lb."updatedAt", FALSE) AS "isLatestBuild",
bld."updatedAt",
bld."publishStatus"
FROM
"Build" bld,
lb
WHERE
bld.deployment IS NOT NULL
AND lb."projectId" = bld."projectId"
AND bld.deployment::jsonb ->> 'projectDomain'::text IS NOT NULL
ORDER BY
bld."projectId",
"domain",
bld."createdAt" DESC,
"buildId";
CREATE OR REPLACE VIEW "LatestBuildPerProjectDomain" AS
WITH lbd AS (
SELECT DISTINCT ON ("projectId",
"domain")
jsonb_array_elements_text(deployment::jsonb -> 'domains') AS "domain",
bld.id AS "buildId",
bld."projectId",
bld."updatedAt",
bld."publishStatus"
FROM
"Build" bld
WHERE
bld.deployment IS NOT NULL
ORDER BY
bld."projectId",
"domain",
bld."createdAt" DESC,
"buildId"
),
lb AS (
SELECT DISTINCT ON ("projectId")
bld.id AS "buildId",
bld."projectId",
bld."updatedAt",
bld."publishStatus"
FROM
"Build" bld
WHERE
bld.deployment IS NOT NULL
AND bld.deployment::jsonb ->> 'projectDomain'::text IS NOT NULL
ORDER BY
bld."projectId",
bld."createdAt" DESC,
"buildId"
)
SELECT
d.id AS "domainId",
lbd."projectId",
lbd."buildId",
coalesce(lbd."updatedAt" = lb."updatedAt", FALSE) AS "isLatestBuild",
lbd."publishStatus",
lbd."updatedAt"
FROM
lbd,
lb,
"Domain" d
WHERE
lbd.domain = d.domain
AND lb."projectId" = lbd."projectId";
@@ -0,0 +1,17 @@
CREATE OR REPLACE VIEW "LatestStaticBuildPerProject" AS
SELECT DISTINCT ON ("projectId")
bld.id AS "buildId",
bld."projectId",
bld."updatedAt",
bld."publishStatus"
FROM
"Build" bld
WHERE
bld.deployment IS NOT NULL
AND bld.deployment::jsonb ->> 'destination'::text = 'static'
ORDER BY
bld."projectId",
bld."createdAt" DESC,
"buildId";
+24 -8
View File
@@ -139,21 +139,22 @@ view UserProduct {
}
model Project {
id String @id @default(uuid())
createdAt DateTime @default(now())
id String @id @default(uuid())
createdAt DateTime @default(now())
title String
domain String @unique
user User? @relation(fields: [userId], references: [id])
domain String @unique
user User? @relation(fields: [userId], references: [id])
userId String?
build Build[]
isDeleted Boolean @default(false)
isDeleted Boolean @default(false)
files File[]
projectDomain ProjectDomain[]
latestBuild LatestBuildPerProject?
authorizationToken AuthorizationToken[]
previewImageAsset Asset? @relation(fields: [previewImageAssetId, id], references: [id, projectId])
previewImageAsset Asset? @relation(fields: [previewImageAssetId, id], references: [id, projectId])
previewImageAssetId String?
marketplaceApprovalStatus MarketplaceApprovalStatus @default(UNLISTED)
marketplaceApprovalStatus MarketplaceApprovalStatus @default(UNLISTED)
latestStaticBuild LatestStaticBuildPerProject?
@@unique([id, isDeleted])
@@unique([domain, isDeleted])
@@ -199,7 +200,8 @@ model Build {
deployment String?
publishStatus PublishStatus @default(PENDING)
latestBuildPerProject LatestBuildPerProject?
latestBuildPerProject LatestBuildPerProject?
latestStaticBuildPerProject LatestStaticBuildPerProject?
@@id([id, projectId])
// ApprovedMarketplaceProduct view performance index
@@ -322,6 +324,20 @@ view LatestBuildPerProject {
@@unique([buildId, projectId])
}
view LatestStaticBuildPerProject {
buildId String
build Build @relation(fields: [buildId, projectId], references: [id, projectId])
projectId String
project Project @relation(fields: [projectId], references: [id])
publishStatus PublishStatus
updatedAt DateTime
@@id([projectId])
@@unique([buildId, projectId])
}
// Dashboard
view DashboardProject {
id String @id @default(uuid())
+1
View File
@@ -25,6 +25,7 @@ export const loadById = async (
where: { id_isDeleted: { id: projectId, isDeleted: false } },
include: {
latestBuild: true,
latestStaticBuild: true,
previewImageAsset: true,
},
});
+9
View File
@@ -43,6 +43,15 @@ export const Project = z.object({
})
.nullable()
),
latestStaticBuild: z.optional(
z
.object({
buildId: z.string(),
publishStatus: z.enum(["PENDING", "PUBLISHED", "FAILED"]),
updatedAt: z.date().transform((date) => date.toISOString()),
})
.nullable()
),
marketplaceApprovalStatus: MarketplaceApprovalStatus,
});
@@ -17,19 +17,19 @@ export const RemixForm = forwardRef<
// resulting in our UI displaying a list that encompasses both variants.
method?: Lowercase<NonNullable<FormProps["method"]>>;
}
>((props, ref) => {
>(({ action, ...props }, ref) => {
const { renderer } = useContext(ReactSdkContext);
// remix casts action to relative url
if (
props.action === undefined ||
props.action === "" ||
props.action?.startsWith("/")
action === undefined ||
action === "" ||
(typeof action === "string" && action?.startsWith("/"))
) {
// remix forms specifies own action instead of provided one
// which makes it hard to handle intercepted submit events
// render remix form only in published sites
if (renderer !== "canvas" && renderer !== "preview") {
return <Form {...props} ref={ref} />;
return <Form action={action} {...props} ref={ref} />;
}
}
return <form {...props} ref={ref} />;
@@ -120,7 +120,11 @@ export const WebhookForm = forwardRef<
ref={ref}
onSubmit={handleSubmitAndAddHiddenJsField}
>
<input type="hidden" name={formIdFieldName} value={action} />
<input
type="hidden"
name={formIdFieldName}
value={action?.toString()}
/>
{children}
</fetcher.Form>
);
@@ -1,3 +1,6 @@
{
"extends": "@webstudio-is/tsconfig/base.json"
"extends": "@webstudio-is/tsconfig/base.json",
"compilerOptions": {
"types": ["react/canary", "@types/node"]
}
}
@@ -65,6 +65,15 @@ export const Image = forwardRef<
assetName = src.slice(assetBaseUrl.length);
}
console.info(
"assetName",
assetName,
"src",
src,
"assetBaseUrl",
assetBaseUrl
);
return (
<WebstudioImage
/**
+13 -4
View File
@@ -1,8 +1,17 @@
import { z } from "zod";
export const Deployment = z.object({
domains: z.array(z.string()),
projectDomain: z.string(),
});
export const Deployment = z.union([
z.object({
destination: z.literal("static"),
name: z.string(),
assetsDomain: z.string(),
templates: z.array(z.string()),
}),
z.object({
destination: z.literal("saas").optional(),
domains: z.array(z.string()),
projectDomain: z.string(),
}),
]);
export type Deployment = z.infer<typeof Deployment>;
@@ -7,6 +7,7 @@ const PublishInput = z.object({
builderOrigin: z.string(),
githubSha: z.string().optional(),
destination: z.enum(["saas", "static"]),
// preview support
branchName: z.string(),
// action log helper (not used for deployment, but for action logs readablity)
+6
View File
@@ -492,6 +492,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.3.1(vite@5.3.4(@types/node@20.12.7))
prettier:
specifier: 3.3.2
version: 3.3.2
typescript:
specifier: 5.5.2
version: 5.5.2
@@ -1399,6 +1402,9 @@ importers:
'@webstudio-is/trpc-interface':
specifier: workspace:*
version: link:../trpc-interface
nanoid:
specifier: ^5.0.1
version: 5.0.1
zod:
specifier: ^3.22.4
version: 3.22.4