Builder cannot connect to site using Hydrogen

Detailed steps to reproduce the bug

I’ve been trying to set up a clean Hydrogen project with builder. I cannot get the preview to load in builder. I just have the Preview Load Error and the popup saying We are having trouble connecting to your site. The page is published and I’m not getting a 404 when visiting the URL which is a good sign that somethings working (http://localhost:3000/test-page) and I see some builder related logs for that page:

Steps to reproduce:

  1. Create a new hydrogen project following the instructions on their website (I used their default CLI options to create a mock store) - this all works fine and I can run it on the localhost:3000: Getting started with Hydrogen and Oxygen

  2. I tried to use the automated builder integration. This made some changes to the package.json etc. The first time I tried it, it did prompt for authorisation, but not when retrying with new clean projects. The builder logo has never appeared at the bottom right when running the dev server: (i.e. “1. On the bottom right, click on the Builder logo to get to your components, settings, and adding a Builder Page.”): Using Builder Devtools for Automated Integration - Builder.io

  3. I then switched to the developer quickstart instructions for hydrogen (Developer Quickstart - Builder.io). In step 2, I replaced the entire template $.tsx that hydrogen generated with the code provided. The line import type { LoaderArgs } from "@remix-run/node"; resulted in an error (Module '"@remix-run/node"' has no exported member 'LoaderArgs'.ts(2305)) which I then changed to LoaderFunctionArgs based on some googling which is hopefully the right thing to do.

  4. I then continued to follow the developer quickstart instructions and set up the models and a new page which I published on builder.io and put in my public API key. The page when running the dev server (http://localhost:3000/test-page) is returning 200 which makes me think some part of the setup is working, but the builder website cannot connect to the site.

Public Repo
Here is a public repo of the full codebase including my public API key:

Code stack you are integrating Builder with
Hydrogen (shopify mock store), typescript, tailwind, node v20.11.0

I worked out that this is a content security policy issue: Getting the Preview URL Working - Builder.io

The default hydrogen setup sets up security rules that block builder out of the box. So the builder quickstart docs are guaranteed to fail for everyone using hydrogen at the moment.

For now, I’ve tabbed out line 35 in entry.server.tsx, but there will be a proper way to configure the CSP for hydrogen.

Please update here if you know this correct configuration / if a PR fixes this for the automated builder integration tool:

Hello @dsg38,

Thank you for bringing this potential issue to our attention. Your feedback is valuable, and we will share it with our team for further investigation. If necessary, we will work on deploying a proper solution. We appreciate your contribution.

Best regards,

import type {EntryContext, AppLoadContext} from '@shopify/remix-oxygen';
import {RemixServer} from '@remix-run/react';
import isbot from 'isbot';
import {renderToReadableStream} from 'react-dom/server';
import {createContentSecurityPolicy} from '@shopify/hydrogen';

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
  context: AppLoadContext,
) {
  const {nonce, header, NonceProvider} = createContentSecurityPolicy({
    shop: {
      checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN,
      storeDomain: context.env.PUBLIC_STORE_DOMAIN,
    },
  });

  // Function to add 'https://cdn.builder.io' to the existing connect-src and add script-src with nonce
  const updateCSP = (header: string, nonce: string) => {
    const directives = header.split(';');
  
    // Update or add connect-src (no nonce here)
    const connectSrcIndex = directives.findIndex(dir => dir.trim().startsWith('connect-src'));
    if (connectSrcIndex > -1) {
      directives[connectSrcIndex] = `${directives[connectSrcIndex]} https://cdn.builder.io`;
    } else {
      directives.push(`connect-src 'self' https://cdn.builder.io`);
    }
  
    // Add or update script-src directive with 'nonce' and builder.io
    const scriptSrcIndex = directives.findIndex(dir => dir.trim().startsWith('script-src'));
    if (scriptSrcIndex > -1) {
      directives[scriptSrcIndex] = `${directives[scriptSrcIndex]} https://cdn.builder.io 'unsafe-eval' 'nonce-${nonce}'`;
    } else {
      directives.push(`script-src 'self' https://cdn.shopify.com https://shopify.com https://cdn.builder.io 'unsafe-eval' 'nonce-${nonce}'`);
    }
  
    // Add or update img-src to allow images from trusted sources
    const imgSrcIndex = directives.findIndex(dir => dir.trim().startsWith('img-src'));
    if (imgSrcIndex > -1) {
      directives[imgSrcIndex] = `${directives[imgSrcIndex]} https://cdn.shopify.com https://shopify.com https://cdn.builder.io`;
    } else {
      directives.push(`img-src 'self' https://cdn.shopify.com https://shopify.com https://cdn.builder.io`);
    }
  
    // Add or update font-src to allow fonts from trusted sources
    const fontSrcIndex = directives.findIndex(dir => dir.trim().startsWith('font-src'));
    if (fontSrcIndex > -1) {
      directives[fontSrcIndex] = `${directives[fontSrcIndex]} https://fonts.gstatic.com https://cdn.shopify.com https://cdn.builder.io`;
    } else {
      directives.push(`font-src 'self' https://fonts.gstatic.com https://cdn.shopify.com https://cdn.builder.io`);
    }
  
    // Update frame-ancestors to allow embedding from 'self', 'localhost', and 'cdn.builder.io'
    const frameAncestorsIndex = directives.findIndex(dir => dir.trim().startsWith('frame-ancestors'));
    if (frameAncestorsIndex > -1) {
      directives[frameAncestorsIndex] = `frame-ancestors 'self' http://localhost:3000 https://builder.io`;
    } else {
      directives.push(`frame-ancestors 'self' http://localhost:3000 https://builder.io`);
    }
  
    return directives.join('; ');
  };
  
  

  const updatedHeader = updateCSP(header, nonce);

  // Pass nonce to render inline script securely
  const body = await renderToReadableStream(
    <NonceProvider>
      <RemixServer context={remixContext} url={request.url} />
    </NonceProvider>,
    {
      nonce,
      signal: request.signal,
      onError(error) {
        console.error(error);
        responseStatusCode = 500;
      },
    }
  );

  if (isbot(request.headers.get('user-agent'))) {
    await body.allReady;
  }

  responseHeaders.set('Content-Type', 'text/html');
  responseHeaders.set('Content-Security-Policy', updatedHeader);

  return new Response(body, {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

I just ran into issues with CSP. This was my solution to get it working with Hydrogen.

Add the above to the entry.server.tsx file.

Then in you pages use this:

import { useNonce } from '@shopify/hydrogen';
export default function Homepage() {
  const data = useLoaderData<typeof loader>();
  const { page } = useLoaderData<typeof loader>();
  const nonce = useNonce();
  return (
    <div className="home">
      <Content model="page" nonce={nonce} apiKey={""} content={page as any} />;
    </div>
  );
}