Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow for dynamically resolving the DraftMode API URL #1280

Open
markomitranic opened this issue Apr 7, 2024 · 10 comments
Open

Allow for dynamically resolving the DraftMode API URL #1280

markomitranic opened this issue Apr 7, 2024 · 10 comments
Labels
feature request New feature or request

Comments

@markomitranic
Copy link

markomitranic commented Apr 7, 2024

Is your feature request related to a problem? Please describe.

The preview plugins (iframe, presentation etc) don't allow us to provide absolute URLs. This hinders our ability to provide a URL resolution mechanism when using multiple domains. This would allow us to use the preview mode with websites that might be spread across multiple domains or subdomains. Think about multi-tenant setups or more commonly next-intl with domains for different locales. A concrete example I have is a project with 24 locales spread across 16 domains, like:

- [da-DK] example.com/
    - [en-DK] example.com/en/
    - [en-US] example.com/en-US/
- [en-GB] example.co.uk/
- [fr-FR] example.co.fr/
- [nl-BE] example.be/
    - [fr-BE] example.be/fr/
.......
  1. Feeding the presentation plugin an absolute preview URL (for example on another domain) isn't possible as it only sends the path to the draft mode API. Update: if we get no.2, then this is no longer an issue since the path belongs to the draftUrl domain.
  2. The draft mode API url is always hardcoded, so a single Studio instance isn't able to invoke multiple different draft APIs.

Describe the solution you'd like

The draft mode API url should be able to be decided on a document by document basis, allowing for us to invoke multiple different domains or subdomains and set cookies for them. We should be able to provide a draft mode URL resolver inside sanity.config.ts, that would receive a Sanity context and the document being queried. Example:

Afaik cookies are set per domain, when using multiple domains, so my studio might be at .com, while preview for this particular document might sit at .co.uk. In this case, the draft endpoint from .co.uk must be invoked, as it can set the cookie for that domain.

Describe alternatives you've considered

See below for example #1280 (comment)
We can get preview URLs to play along, by circumventing @sanity/preview-url-secret, but this still doesn't help with presentation plugins.

Which versions of Sanity are you using?

@sanity/cli (global)                   3.36.4 (up to date)
@sanity/document-internationalization   2.1.0 (latest: 2.1.1)
@sanity/icons                          2.11.6 (latest: 2.11.7)
@sanity/image-url                       1.0.2 (up to date)
@sanity/presentation                   1.12.1 (latest: 1.12.3)
@sanity/ui                             2.0.14 (latest: 2.0.16)
@sanity/vision                         3.36.4 (up to date)
@sanity/webhook                         4.0.3 (up to date)
sanity                                 3.36.4 (up to date)

Which versions of Node.js / npm are you running?

10.2.4
v20.11.1

Additional context
I'd be interested in contributing to this, although I have no idea where to start, so perhaps some advice would be needed. For now, let's see if the repo is interested in this sort of contribution (or perhaps I've missed something and this is entirely possible?)

@markomitranic
Copy link
Author

markomitranic commented Apr 7, 2024

  1. It is my understanding that this is where the override happens:

    return async (context): Promise<string> => {

    I suggest that we change this, so that the origin logic is used only if the given URL is not a valid absolute url, which is btw easy to check for with:

    function isValidUrl(urlString: string): boolean {
        try { return Boolean(new URL(urlString)) } catch (e) { return false }
    }

    Update: I just realized that we don't need to do no.1 if we have no.2 - we have to invoke draft endpoints on each domain anyway, meaning the given URL is relative to that particular domain anyway.

  2. And this is where the draft enable URL is fetched from:

    const enableUrl = previewMode?.enable || draftMode?.enable

    I suggest we simply add an escape hatch that would allow us to define a callback as part of configuration, as an alternative to providing a static string. That'd be enough and allow us to use whatever logic we see fit, including server actions, to decide what draft url should be invoked.

@markomitranic
Copy link
Author

FYI, we have a partial alternative in the meantime, where we circumvent @sanity/preview-url-secret by composing it ourselves. I've explained it here: tinloof/sanity-kit#17 (comment)

This isn't ideal, and while it solves productionUrl preview urls, it still means that we are unable to offer Sanity presentation or live editing to our clients :(

@markomitranic markomitranic changed the title Absolute URL support Allow for dynamically resolving the DraftMode API URL Apr 11, 2024
@mrr11k
Copy link

mrr11k commented Apr 16, 2024

This is the perfect explanation of the issue, I just stumbled across.

The only "solution" in our case (Next.js) would be using the embedded Studio and the draft mode URL set with a env variable. But IMO this would not be a great UX opening multiple embedded studios to edit stuff on multiple domains.

Hopefully this gets some attention soon, as we would also love to offer visual editing to our clients. 🙏

@stipsan stipsan added the feature request New feature or request label Apr 17, 2024
@stipsan
Copy link
Member

stipsan commented Apr 17, 2024

Thanks for sharing @markomitranic 🙌 We'll fast track this and add the escape hatches you need 👍

@stipsan
Copy link
Member

stipsan commented Apr 23, 2024

@markomitranic a status update on what we intend to do, following our deep dive into this so far.

We'll do two feature releases.

1. Add previewMode.check API

In order to call previewMode.enable dynamically then we need a way to check if preview mode is enabled or not.
The MVP configuration looks like this:

presentationTool({
  previewUrl: {
    previewMode: {
      enable: '/api/draft',
      check: '/api/draft-check'
    }
  }
})

The response of /api/draft-check is expected to be:

GET /api/draft-check 200
{
  "version": 1,
  "enabled": boolean
}

A basic implementation of this API in App Router would be:

/* eslint-disable no-process-env */
import {draftMode} from 'next/headers'
import {NextResponse} from 'next/server'

export async function GET() {
  return NextResponse.json({
    version: 1,
    enabled: draftMode().isEnabled,
  })
}

We plan on shipping first class utilities for this in @sanity/preview-url-secret and next-sanity.

With the ability to check if the preview url secret handshake is needed we're able to do improve cold starts and long lived preview sessions where it's necessary to restart Next.js Draft Mode due to newer deployments that happened in the background.

If you don't specify previewMode.check, then previewMode.enable works like before:

  1. Presentation Tool loads, and assembles the iframe URL based on the previewUrl configuration.
  2. The iframe is set to load new URL(${previewUrl.previewMode.enable}?redirect=${previewUrl.preview}, previewUrl.origin), and the API handler is responsible for redirecting the iframe to the preview after setting necessary cookies and what not.
  3. While this is happening the iframe is hidden and a loading spinner is shown, until we receive a postMessage from @sanity/visual-editing letting @sanity/presentation know everything is ready to respond to events.
  4. Iframe fades in and overlays are shown.

If the connection to @sanity/visual-editing is lost then we don't have a way of knowing if that's because the URL of the iframe changed somewhere external, if it's because the app crashed, or because a navigation happened and next had a newer deployment in the background so the current draftMode cookie is no longer valid, and the app is back in production mode. All we know is that the connection is lost.

With previewMode.check we can change the flow:

  1. Call await fetch(previewUrl.previewMode.check, previewUrl.origin).enabled == true.
  2. If enabled is false, call await fetch(previewUrl.previewMode.enable, {method: 'POST', body: {secret}}).
  3. If the enable request is successful, then load the iframe with src={new URL(previewUrl.preview, previewUrl.origin).toString()}.
  4. Wait for a while for the postMessage from @sanity/visual-editing, if received then we're done.
  5. If it takes too long for @sanity/visual-editing to connect, we'll call previewMode.check again and check if we lost the session, if it's reporting enabled: true then we know there's something else preventing the connection (misconfiguration or a crash).
  6. If enabled: false we can retry the handshake automatically a couple of times before giving up and throwing an error that takes you to docs for how to setup preview mode correctly as well as troubleshooting it.

Step 5-6 will also happen should the postMessage connection to @sanity/visual-editing be lost later on in a long lived session.

Finally, while adding this we also want to let you customize the fetch method used for the handshake, allowing you to set custom headers and more:

presentationTool({
  previewUrl: {
    previewMode: {
      enable: '/api/draft',
      check: '/api/draft-check',
      defineFetch: context => (url: URL, init: RequestInit): Promise<Response>
    }
  }
})

2. Support absolute URLs/multiple origins for previews

The locations API, as well as how previewMode works, is built under the assumption that everything you're previewing will happen on a single origin/domain.
The URL bar validation operates under this assumption, so does the locations drawer.
In order to support use cases like locales tied to a specific domain or subdomain, or markets and regional segments, then it becomes necessary to build in support for it in each layer of the stack.
previewMode needs to know when you're moving between domains and which ones need to initiate a handshake, and we might need to expose a way to configure how the handshake is setup based on the origin:

presentationTool({
  previewUrl: {
    previewMode: origin => {
      switch(origin) {
        case 'https://next.vercel.app':
          return {enable: '/api/draft', check: '/api/draft/check'}
        case 'https://hydrogen.shop':
          return {enable: '/api/preview'}
        default:
          // Returning false signals that a handshake shouldn't be attempted, or isn't needed
          return false
      }    
    }
  }
})

The locations API needs to support rendering absolute URLs in its drawer, allowing viewing a e.g. french translation of a document that lives on a different domain than the one currently in the preview iframe.
We'd also need an API for validating the URL, that is used when:

  • a URL is manually entered in the preview URL bar.
  • the document.referer state is read on initial load when there isn't a &preview search parameter in the URL.
  • the &preview search parameter is read from the URL.

@markomitranic
Copy link
Author

markomitranic commented Apr 24, 2024

Dear @stipsan thank you for this lengthy and sturdy plan of action! I am super happy to hear some movement on this, it will change the editor's experience quite a bit if we are able to do this!

I understand that your "RFC" needs to support more consumers than I can think of - live editing, preview links, iframe plugin etc, and many ways of using it - you mention being able to manually type stuff into the address bar for example, I never thought of that. So, I write this while fully appreciating that the issue is more complex than it seems to me, nor am I a library author, so not thinking that I deserve any sort of influence. Just my 2 cents.

We might be overthinking this. The proposed changes seem much more like a long-term course correction, than a simple and non-breaking addition to the API.

Instead of taking it in small steps, going from a string to a simple callback, we are talking about re-engineering how preview URLs work. Instead of focusing on one problem - draftMode api on dynamic domains, we are trying to solve redirections by checks, addressbar behaviour, on-page links, extending the fetch call, live previews and more.

I like the changes, but I don't think they are at all necessary to resolve the issue I've raised:

  • don't see a real point of check. Sure, it will save 0.2s for editors when they are being redirected, but will introduce a bunch of complexity/state that the Sanity team now has to think about forever - Redirect-on-every request (UI or address) served us perfectly fine so far, I don't see why we'd trade that.
  • The only reason we'd have this is if you are clicking links within the iframe, and you click on a link that belongs to a different domain, where a new draftMode may needed. At the end of your proposal you like out some changes to the way Address bar is handled, but I am afraid I don't fully understand those either, why wouldn't it use exactly the same system as the rest of the app does - take the URL, find out if draftMode is needed by sending the origin of the url to previewMode function, don't use draftMode if not. We don't need any additional parameters.
  • Looking at the switch statement in the second part, do I understand it correctly that it would send us the origin as seen on the resolved previewUrl? In that case it is fine, as long as we have an easy mechanism for supporting relative URLs by default.

@stipsan
Copy link
Member

stipsan commented Apr 24, 2024

Hey @markomitranic, I think I'm confused what you mean by:

Redirect-on-every request (UI or address) served us perfectly fine so far, I don't see why we'd trade that.

What do you mean on every request? The current behaviour is that the redirect only happens initially, before we fade in the iframe.
That's why it's challenging to allow previewMode.enable to be a callback, as we only call it in these situations:

  • initially, on load.
  • after the iframe failed to load, or lost connection to @sanity/visual-editing, and you press Retry on the error card.
  • when the url preview secret has expired.

Instead of focusing on one problem - draftMode api on dynamic domains, we are trying to solve redirections by checks, addressbar behaviour, on-page links, extending the fetch call, live previews and more.

The static behavior of draftMode today is centered on the assumption that each instance of presentationTool only has to deal with a single origin.
That's why previewUrl.origin is a string, and doesn't allow an array of strings.
That's why we only initiate the draftMode redirect handshake once, on load.
It's why the addressbar doesn't let you enter other domains.
It's why we hide the iframe initially, to hide the jarring flashes of unstyled white pages that can happen during iframe redirects, and fade in the iframe after we're reasonably certain there won't be ugly layout shifts.

Changing that into a dynamic resolver has side-effects. If we don't have a way of checking if draft mode is enabled or not, then we can't do it with a fetch call as it's possible that:

  • GET 200 /api/draft
    Could be successful, in that it gave a 200, and Next.js returned the cookie header that enables draft mode, but the browser, due to modern strict cookie settings, ignores the cookie so draft mode isn't actually enabled. We need an API like /api/draft-check that can tell us if those cookies are indeed set correctly.
    If we can't reliable enable draft mode with fetch calls, then our only option is to do it in the iframe, which is extremely jarring since the iframe is uncontrolled and we can't enforce smooth and intuitive transitions.
  1. Let's say you're previewing the home page in english, and then click a link that takes you to the french domain and locale.
  2. Since the iframe is uncontrolled, it already starts to load the french site and you see its contents in the frame.
  3. Presentation Tool is notified about the new URL and can see that it's on a new origin. It runs previewUrl.draftMode('https://example.fr') and is given {enable: '/api/draft'} and constructs the redirect URL.
  4. It changes the iframe URL to the redirect URL, this causes the iframe to render a white page while the api handler, which might have a cold start since it's serverless, is firing up and validating the URL secret.
  5. The /api/draft API handler responds with the 302 redirect and the iframe starts to load the page it was on again, and you see the content pop back in.

That's the best case scenario. The more likely scenario is that on step 3:
3. The https://example.fr loads, and since it only loads import {VisualEditing} from 'next-sanity' when draft mode is enabled, there is no @sanity/visual-editing code that sends a postMessage to presentationTool that notifies it about the new URL https://example.fr and presentationTool still thinks its on https://example.com.
4. Presentation is setup with a Marco/Polo postMessage cycle, and since @sanity/visual-editing is no longer loaded, there's no Polo being sent back and a countdown is started. Presentation eventually shows this error:
image
5. If you click on Retry then we'll call draftMode but since Presentation doesn't know about the change of domain it'll give you the last one it knows about, which is http://example.com.
6. Entering https://example.fr in the URL address bar won't work as its validation will prevent you from submitting it.
7. Setting &preview=https://example.fr also won't work because our validation disregards origins that don't match the previewUrl.origin || location.origin setting.

Looking at the switch statement in the second part, do I understand it correctly that it would send us the origin as seen on the resolved previewUrl? In that case it is fine, as long as we have an easy mechanism for supporting relative URLs by default.

Yes, we'll preserve the convenience of relative URLs and installations where you only deal with one origin at a time. The complexity of multiple origins are opt-in. We're not going to introduce any breaking changes in order to deliver this functionality.

@stipsan
Copy link
Member

stipsan commented Apr 24, 2024

Also worth mentioning that the draftMode.defineFetcher addition is added to solve different use cases, such as when you deal with Vercel deployments that have protection enabled, and require some form of bypass to be setup: sanity-io/next-sanity#770 (comment)
It's also to allow similar security requirements where you may have to send certain headers or extra secrets to bypass strict firewalls.
It's mentioned here since it also allow you to customize requirements that may be necessary in the context of intl. For example you may have middleware setup that makes it necessary for the fetch to /api/draft to have the correct Accept-Language header to avoid it redirecting you to a different locale than what you're trying to navigate to.

@markomitranic
Copy link
Author

markomitranic commented Apr 29, 2024

Hey @stipsan sorry for the late reply, I was quite busy over the past few days. Thank you for the response I think i understand the intent much better now!

After reading your response I am starting to realize that it might be smart for me to stick with using the productionUrl buttons, as they (unfortunately) make for a better experience for the editors. They require zero IQ to use, you click a button, observe a page and reload it when you wish to. There are no edge cases, weird states or address bars to worry about. Perhaps I'll try to use the raw iframe plugin again to achieve it.

Here are some clarifications of functional expectations from my part, that I now understand are not really the goal of the presentation plugins:

What do you mean on every request? The current behaviour is that the redirect only happens initially, before we fade in the iframe.

Oh ok my mistake then, I suppose me using iframe plugin directly to achieve this has muddied up how the real thing works in my mind. I thought that draft gets hit on every "request", with request meaning when you click the preview button, or reload button.

The static behavior of draftMode today is centered on the assumption that each instance of presentationTool only has to deal with a single origin.
That's why previewUrl.origin is a string, and doesn't allow an array of strings.
That's why we only initiate the draftMode redirect handshake once, on load.
It's why the addressbar doesn't let you enter other domains.
It's why we hide the iframe initially, to hide the jarring flashes of unstyled white pages that can happen during iframe redirects, and fade in the iframe after we're reasonably certain there won't be ugly layout shifts.

This is percicely what I meant by saying - overcomplicating. Sounds like you are trying to protect the users from themselves by manually adding these limitations. For example, the iframe plugin itself works just fine and dandy with typing outside domains in the address bar. The only reason why it doesn't in presentation, is that you probably made that decision yourselves in order to make it a smoother experience for the end user. Same with white flashes - that is how the whole internet works, its fine, no reason to hide them from the users. In fact it makes it feel weird, since as a user you don't really know what is going on, as the loading cues you are used to aren't there. In my opinion these constraints are just making it harder to use the tool, not easier.

The presentation tool shouldn't be trying to reinvent the browser, it should just show pages when user clicks preview or reload, and refresh them every time a draft is updated. Clunky and simple and old-school. Apart from (obviously) the fancy live editing plugin, the presentation tool itself should basically be an equivalent of you having 2 windows open side by side, preview with autorefresh and Sanity editor.

Next.js returned the cookie header that enables draft mode, but the browser, due to modern strict cookie settings, ignores the cookie so draft mode isn't actually enabled. We need an API like /api/draft-check that can tell us if those cookies are indeed set correctly.

Good point, that is a very good use for this endpoint. Currently we simply let the users know by showing an "EXIT PREVIEW MODE" button (top bar) on every page while the user is in preview mode. This puts the control away from automated systems and into the user's hands, where they not only explicitly know if they are in preview mode, but can also choose to exit preview mode themselves.

Let's say you're previewing the home page in english, and then click a link that takes you to the french domain and locale.

Yup, in my opinion this is exactly what should happen. The user should end up on whichever page they wish to end up, doesn't matter if it is the French website or wikipedia. The draft mode should not automagically activate when you click on links within the iframe (unless its already active for that domain ofc) it should only activate by you clicking a Sanity UI action on a corresponding document that has a preview url on a given page or domain.

To turn your example around - if you are editing an english version of a document in Sanity, you change to French through the Sanity UI, the iFrame should refresh with the draft mode on the FR website. If you, however click on the language picker inside the iframe, you are on your own, since your iframe context is now doing something entirely different than the editing UI around it is displaying (the EN version of the document).

If you click on Retry then we'll call draftMode but since Presentation doesn't know about the change of domain it'll give you the last one it knows about, which is http://example.com.

Yup, percisely what should happen - you are editing the EN version. The fact that you navigated away to FR is not presentation's concern. All UI actions such as Retry should be related to the document you are editing, not to the content of the iframe.

Entering https://example.fr in the URL address bar won't work as its validation will prevent you from submitting it.

I may be wrong but I absolutely recall entering multiple domains into the raw iframe plugin. This was a while ago so perhaps I am misremembering... Oh or perhaps I didn't enter it, but used the hook to force a navigate action! 😕 maybe that is what i remember.

We're not going to introduce any breaking changes in order to deliver this functionality.

phew, glad to hear that

the draftMode.defineFetcher addition is added to solve different use cases, such as when you deal with Vercel deployments that have protection enabled, and require some form of bypass to be setup

Ah, super useful, a header and a token is a common practice when it comes to CDNs, so I'm super glad to hear this!

@stipsan
Copy link
Member

stipsan commented May 16, 2024

Hey @markomitranic, just a status update 👋

The last couple of weeks I've had my hands full keeping up with turbopack fixes for the upcoming release of next dev --turbo, as well as hydrogen and high priority bugs in general. And moving forward in the near term I'm assigned to other feature work.

You're welcome to make the changes you need to these packages and send a PR for us to look at with the escape hatches you need, we could perhaps add a feature flag that's tagged as @alpha so existing behaviour is unaffected.
Other than that just be aware that the model of iframe-pane-plugin and presentation have the fundemental difference that by the time iframe-pane-plugin renders you always have a parent document that renders it. The same is true for resolveProductionUrl, there's always a document _id and _type. It might take time to resolve as the route loads, but this guarantee is consistent.
With Presentation the model is different, the preview always loads first. We then either render a list over documents on the right, or open a document if a presentationTool.resolve.mainDocuments pattern matches the URL (docs).

Also worth knowing that we started off building Presentation by using the iframe pane plugin as the foundation, we didn't want to reinvent the wheel or "overcomplicate" things, quite the opposite. But eventually this hard constraint on the preview frame always being present, but not always know what the current URL is just yet, or know ahead of time what documents are used on the page, or wether the application inside the preview is showing only published content, or the expected draft content, these constraints force decisions to be made as I mentioned last time.
We feel the constraint is worth it, as the resulting end-user experience, as well as the future potential for the kind of page builder features we want to support first-class, make it valuable.
These constraints doesn't allow us to build something that is "Clunky and simple and old-school.". Additionally, if that's the experience you want, then we already offer it by the means of sanity-plugin-iframe-pane and resolveProductionUrl. And that's fine, those solutions will continue to be maintained :)
The whole reason we ventured out to build sanity/presentation is that, while we felt the UX with sanity-plugin-iframe-pane were excellent as it is, we wanted to take it multiple steps further and push the envelope on what a CMS can do if live previews are upgraded from an experience you can opt-in to on a document level, into a first class experience where the preview itself drives the workflow, and the Studio just adapts to whatever you're looking at and reduces as much clutter as possible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature request New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants