Shopify / hydrogen

Hydrogen lets you build faster headless storefronts in less time, on Shopify.
https://hydrogen.shop
MIT License
1.38k stars 265 forks source link

Deploy hydrogen to cloudflare workers and lose all the css styling #789

Closed xnslx closed 1 year ago

xnslx commented 1 year ago

What is the location of your example repository?

No response

Which package or tool is having this issue?

Hydrogen

What version of that package or tool are you using?

"@shopify/hydrogen": "^2023.1.6"

What version of Remix are you using?

No response

Steps to Reproduce

I managed to deploy shopify hydrogen V2 to Cloudflare workers and I lose all the CSS styling. There is no way to resolve the issue.

package.json

{
  "name": "demo-store",
  "private": true,
  "version": "0.0.0",
  "scripts": {
    "build": "npm run build:css && shopify hydrogen build",
    "build:css": "postcss styles --base styles --dir app/styles --env production",
    "deploy": "wrangler publish",
    "dev": "npm run build:css && concurrently -g --kill-others-on-fail -r npm:dev:css \"shopify hydrogen dev\"",
    "dev:css": "postcss styles --base styles --dir app/styles -w",
    "preview": "npm run build && shopify hydrogen preview",
    "lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .",
    "format": "prettier --write --ignore-unknown .",
    "format:check": "prettier --check --ignore-unknown .",
    "typecheck": "tsc --noEmit"
  },
  "prettier": "@shopify/prettier-config",
  "dependencies": {
    "@cloudflare/kv-asset-handler": "^0.3.0",
    "@headlessui/react": "^1.7.2",
    "@remix-run/cloudflare": "^1.15.0",
    "@remix-run/cloudflare-workers": "^1.15.0",
    "@remix-run/react": "1.14.0",
    "@shopify/cli": "3.29.0",
    "@shopify/cli-hydrogen": "^4.0.9",
    "@shopify/hydrogen": "^2023.1.6",
    "@shopify/remix-oxygen": "^1.0.4",
    "clsx": "^1.2.1",
    "concurrently": "^7.5.0",
    "cross-env": "^7.0.3",
    "graphql": "^16.6.0",
    "graphql-tag": "^2.12.6",
    "isbot": "^3.6.5",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-intersection-observer": "^9.4.1",
    "react-use": "^17.4.0",
    "schema-dts": "^1.1.0",
    "tiny-invariant": "^1.2.0",
    "typographic-base": "^1.0.4"
  },
  "devDependencies": {
    "@remix-run/dev": "1.14.0",
    "@remix-run/eslint-config": "1.14.0",
    "@shopify/eslint-plugin": "^42.0.1",
    "@shopify/oxygen-workers-types": "^3.17.2",
    "@shopify/prettier-config": "^1.1.2",
    "@tailwindcss/forms": "^0.5.3",
    "@tailwindcss/typography": "^0.5.7",
    "@types/eslint": "^8.4.10",
    "@types/react": "^18.0.20",
    "@types/react-dom": "^18.0.6",
    "concurrently": "^7.4.0",
    "cross-env": "^7.0.3",
    "eslint": "^8.20.0",
    "eslint-plugin-hydrogen": "0.12.2",
    "postcss": "^8.4.16",
    "postcss-cli": "^10.0.0",
    "postcss-import": "^15.0.0",
    "postcss-preset-env": "^7.8.2",
    "prettier": "^2.8.4",
    "rimraf": "^3.0.2",
    "tailwindcss": "^3.1.8",
    "typescript": "^4.9.5",
    "wrangler": "^2.15.0"
  },
  "engines": {
    "node": ">=16.13"
  }
}

wrangler.toml

name = "remix-cloudflare-workers"

workers_dev = true
# https://developers.cloudflare.com/workers/platform/compatibility-dates
compatibility_date = "2023-03-01"

main = "./dist/worker/index.js"

[site]
  bucket = "./public"

[build]
  command = "npm run build"

[vars]
  SESSION_SECRET="foobar"
  PUBLIC_STOREFRONT_API_TOKEN="c38d523249f4cd5c9c0ef593c158c335"
  PUBLIC_STOREFRONT_API_VERSION="2023-01"
  PUBLIC_STORE_DOMAIN="example.myshopify.com"

server.ts

// Virtual entry point for the app
import * as remixBuild from '@remix-run/dev/server-build';
import {
  createRequestHandler,
  getStorefrontHeaders,
} from '@shopify/remix-oxygen';
import {createStorefrontClient, storefrontRedirect} from '@shopify/hydrogen';
import {HydrogenSession} from '~/lib/session.server';
import {getLocaleFromRequest} from '~/lib/utils';

/**
 * Export a fetch handler in module format.
 */
export default {
  async fetch(
    request: Request,
    env: Env,
    executionContext: ExecutionContext,
  ): Promise<Response> {
    try {
      /**
       * Open a cache instance in the worker and a custom session instance.
       */
      if (!env?.SESSION_SECRET) {
        throw new Error('SESSION_SECRET environment variable is not set');
      }

      const waitUntil = (p: Promise<any>) => executionContext.waitUntil(p);
      const [cache, session] = await Promise.all([
        caches.open('hydrogen'),
        HydrogenSession.init(request, [env.SESSION_SECRET]),
      ]);

      /**
       * Create Hydrogen's Storefront client.
       */
      const {storefront} = createStorefrontClient({
        cache,
        waitUntil,
        i18n: getLocaleFromRequest(request),
        publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN,
        privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
        storeDomain: `https://${env.PUBLIC_STORE_DOMAIN}`,
        storefrontApiVersion: env.PUBLIC_STOREFRONT_API_VERSION || '2023-01',
        storefrontId: env.PUBLIC_STOREFRONT_ID,
        storefrontHeaders: getStorefrontHeaders(request),
      });

      /**
       * Create a Remix request handler and pass
       * Hydrogen's Storefront client to the loader context.
       */
      const handleRequest = createRequestHandler({
        build: remixBuild,
        mode: process.env.NODE_ENV,
        getLoadContext: () => ({cache, session, waitUntil, storefront, env}),
      });

      const response = await handleRequest(request);

      if (response.status === 404) {
        /**
         * Check for redirects only when there's a 404 from the app.
         * If the redirect doesn't exist, then `storefrontRedirect`
         * will pass through the 404 response.
         */
        return storefrontRedirect({request, response, storefront});
      }

      return response;
    } catch (error) {
      // eslint-disable-next-line no-console
      console.error(error);
      return new Response('An unexpected error occurred', {status: 500});
    }
  },
};

remix.config.js

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  appDirectory: 'app',
  ignoredRouteFiles: ['**/.*'],
  watchPaths: ['./public'],
  server: './server.ts',
  /**
   * The following settings are required to deploy Hydrogen apps to Oxygen:
   */
  publicPath: (process.env.HYDROGEN_ASSET_BASE_URL ?? '/') + 'build/',
  assetsBuildDirectory: 'dist/client/build',
  serverBuildPath: 'dist/worker/index.js',
  serverMainFields: ['browser', 'module', 'main'],
  serverConditions: ['worker', process.env.NODE_ENV],
  serverDependenciesToBundle: 'all',
  serverModuleFormat: 'esm',
  serverPlatform: 'neutral',
  serverMinify: process.env.NODE_ENV === 'production',
};

app.server.tsx

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

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
) {
  const body = await renderToReadableStream(
    <RemixServer context={remixContext} url={request.url} />,
    {
      signal: request.signal,
      onError(error) {
        // eslint-disable-next-line no-console
        console.error(error);
        responseStatusCode = 500;
      },
    },
  );

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

  responseHeaders.set('Content-Type', 'text/html');
  return new Response(body, {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

Expected Behavior

Hope to deploy a fully working website to cloudflare workers.

Actual Behavior

The current live website losing all the CSS styling.

frandiox commented 1 year ago

[site] bucket = "./public"

I think your bucket should point to dist/client instead, since that's where the assets are built.

lordofthecactus commented 1 year ago

How are you adding your styling to your components? Plain css?

How are you deploying your styles to cloudflare workers?

If you go to https://hydrogen.shop you can inspect how styles are added a deploy:

They should be in the <head> as a <link>. For example:

<link rel="stylesheet" href="https://cdn.shopify.com/oxygen/55145660472/28966968/maqqspnc1/build/_assets/app-QL2UZK4U.css">

In the case of cloudflare, you'd need to check if the file is getting uploaded and if the file is in the of your project.

xnslx commented 1 year ago

@lordofthecactus @frandiox I finally made it using this https://github.com/lordofthecactus/hydrogen-v2-cloudflare-edge as a reference. There is a little bit adjustment for remix.config.js file,

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  devServerBroadcastDelay: 1000,
  ignoredRouteFiles: ['**/.*'],
  server: './server.js',
  serverConditions: ['worker'],
  serverDependenciesToBundle: 'all',
  serverMainFields: ['browser', 'module', 'main'],
  serverMinify: true,
  serverModuleFormat: 'esm',
  serverPlatform: 'neutral',
  appDirectory: 'app',
  assetsBuildDirectory: 'public/build',
  serverBuildPath: 'build/index.js',
  publicPath: '/build/',
  future: {
    unstable_postcss: true,
    unstable_tailwind: true,
    v2_meta: true,
    v2_routeConvention: true,
    v2_errorBoundary: true,
    v2_normalizeFormMethod: true,
  },
};

But I do have a question, for the server.ts file, there is a typescirpt issue that I cannot resolve.

Screen Shot 2023-04-18 at 10 32 52 AM
lordofthecactus commented 1 year ago

Great! Could you show the issue when you hover?

xnslx commented 1 year ago

@lordofthecactus This is the issue

Screen Shot 2023-04-18 at 11 02 39 AM

server.ts

import * as build from '@remix-run/dev/server-build';
import {createCookieSessionStorage, ServerBuild} from '@remix-run/cloudflare';
import type {GetLoadContextFunction} from '@remix-run/cloudflare-workers';
import {
  createRequestHandler as createRemixRequestHandler,
  Session,
  SessionStorage,
} from '@remix-run/server-runtime';
import type {Options as KvAssetHandlerOptions} from '@cloudflare/kv-asset-handler';
import {
  MethodNotAllowedError,
  NotFoundError,
  getAssetFromKV,
} from '@cloudflare/kv-asset-handler';
import {createStorefrontClient} from '@shopify/hydrogen';
import {getBuyerIp} from '@shopify/remix-oxygen';
import {HydrogenSession} from '~/lib/session.server';
import {getLocaleFromRequest} from '~/lib/utils';

type RequestHandler = (event: FetchEvent) => Promise<Response>;

addEventListener(
  'fetch',
  createEventHandler({build, mode: process.env.NODE_ENV}),
);

function createEventHandler({
  build,
  getLoadContext,
  mode,
}: {
  build: ServerBuild;
  getLoadContext?: GetLoadContextFunction;
  mode?: string;
}) {
  const handleEvent = async (event: FetchEvent) => {
    /**
     * Open a cache instance in the worker and a custom session instance.
     */
    if (!SESSION_SECRET) {
      throw new Error('SESSION_SECRET environment variable is not set');
    }

    const waitUntil = (p: Promise<any>) => event.waitUntil(p);
    const env = {
      PUBLIC_STOREFRONT_API_TOKEN,
      PRIVATE_STOREFRONT_API_TOKEN:
        typeof PRIVATE_STOREFRONT_API_TOKEN !== 'undefined'
          ? PRIVATE_STOREFRONT_API_TOKEN
          : undefined,
      PUBLIC_STORE_DOMAIN,
      PUBLIC_STOREFRONT_API_VERSION,
      PUBLIC_STOREFRONT_ID:
        typeof PUBLIC_STOREFRONT_ID !== 'undefined'
          ? PUBLIC_STOREFRONT_ID
          : undefined,
      SESSION_SECRET,
    };

    const [cache, session] = await Promise.all([
      caches.open('hydrogen'),
      HydrogenSession.init(event.request, [SESSION_SECRET]),
    ]);

    /**
     * Create Hydrogen's Storefront client.
     */
    const {storefront} = createStorefrontClient({
      // cache
      waitUntil,
      buyerIp: getBuyerIp(event.request),
      i18n: getLocaleFromRequest(event.request),
      publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN,
      privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
      storeDomain: `https://${env.PUBLIC_STORE_DOMAIN}`,
      storefrontApiVersion: env.PUBLIC_STOREFRONT_API_VERSION || '2023-01',
      storefrontId: env.PUBLIC_STOREFRONT_ID,
      requestGroupId: event.request.headers.get('request-id'),
    });

    const handleRequest = createRequestHandler({
      build,
      getLoadContext: () => ({session, cache, storefront}),
      mode,
    });

    let response = await handleAsset(event, build);

    if (!response) {
      response = await handleRequest(event);
    }

    return response;
  };

  return (event: FetchEvent) => {
    try {
      event.respondWith(handleEvent(event));
    } catch (e: any) {
      if (process.env.NODE_ENV === 'development') {
        event.respondWith(
          new Response(e.message || e.toString(), {
            status: 500,
          }),
        );
        return;
      }

      event.respondWith(new Response('Internal Error', {status: 500}));
    }
  };
}

function createRequestHandler({
  build,
  getLoadContext,
  mode,
}: {
  build: ServerBuild;
  getLoadContext?: GetLoadContextFunction;
  mode?: string;
}): RequestHandler {
  const handleRequest = createRemixRequestHandler(build, mode);

  return (event: FetchEvent) => {
    const loadContext = getLoadContext?.(event);

    return handleRequest(event.request, loadContext);
  };
}

async function handleAsset(
  event: FetchEvent,
  build: ServerBuild,
  options?: Partial<KvAssetHandlerOptions>,
) {
  try {
    if (process.env.NODE_ENV === 'development') {
      return await getAssetFromKV(event, {
        cacheControl: {
          bypassCache: true,
        },
        ...options,
      });
    }

    let cacheControl = {};
    const url = new URL(event.request.url);
    const assetpath = build.assets.url.split('/').slice(0, -1).join('/');
    const requestpath = url.pathname.split('/').slice(0, -1).join('/');

    if (requestpath.startsWith(assetpath)) {
      // Assets are hashed by Remix so are safe to cache in the browser
      // And they're also hashed in KV storage, so are safe to cache on the edge
      cacheControl = {
        bypassCache: false,
        edgeTTL: 31536000,
        browserTTL: 31536000,
      };
    } else {
      // Assets are not necessarily hashed in the request URL, so we cannot cache in the browser
      // But they are hashed in KV storage, so we can cache on the edge
      cacheControl = {
        bypassCache: false,
        edgeTTL: 31536000,
      };
    }

    return await getAssetFromKV(event, {
      cacheControl,
      ...options,
    });
  } catch (error: unknown) {
    if (
      error instanceof MethodNotAllowedError ||
      error instanceof NotFoundError
    ) {
      return null;
    }

    throw error;
  }
}
frandiox commented 1 year ago

@xnslx You can either pass the env object to getLoadContext (if you are going to use it in loaders) or remove its type declaration from the remix.env.d.ts file:

// server.ts
-  getLoadContext: () => ({session, cache, storefront}),
+  getLoadContext: () => ({session, cache, storefront, env}),

or

// remix.env.d.ts
declare module '@shopify/remix-oxygen' {
  export interface AppLoadContext {
    waitUntil: ExecutionContext['waitUntil'];
    session: HydrogenSession;
    storefront: Storefront;
-    env: Env;
  }
}