Skip to content

Instantly share code, notes, and snippets.

@jplhomer
Created March 2, 2023 17:02
Show Gist options
  • Save jplhomer/a1ea217047e27888aec8e695b563ace2 to your computer and use it in GitHub Desktop.
Save jplhomer/a1ea217047e27888aec8e695b563ace2 to your computer and use it in GitHub Desktop.

Deploying Hydrogen on a different hosting runtime

So, you want to use Hydrogen, but you don't want to use Oxygen?! You silly scaggly-waggly. No worriesβ€”we can help.

Remix can be deployed to lots of different places. Just look at this list! Anywhere Remix can be deployed, Hydrogen can be deployed.

Rather than trying to provide you an exhaustive example for every single adapter today and in the future, let's focus on a fictional hosting runtime: Funkytown Hosting.

We'll pretend that you've already spun up a Hydrogen app using the shopify hydrogen CLI, and you plan to convert it to Funkytown.

Let's walk through some situations you might encounter on your journey!

But first: Some background

Remix hosting adapters take lots of different forms. This is to be expected! Vercel has a Build Output API, Cloudflare Pages has its own set of rules... The list goes on.

Tip: The absolute best way to start migrating to a different platform is to read the platform's docs, and to take a look at the hosting adapters Remix template.

Where do you find these hosting templates? Good question. Remix hosts them in its repo today, but that won't always be the case. Check your hosting platform's docs to see where the adapter's code lives.

Each platform will have its own constraints, including:

  • The CLI they use to run a local development server
  • Updating and accessing environment variables
  • Caching: whether they provide a Cache API and how to access it
  • Which headers are available to contextualize the visitor

All of these constraints ^ will affect how you set up your Hydrogen app, so you should take some time to familiarize yourself with these constraints in your hosting platform.

Back to Funkytown

Let's pretend we got a heck of a deal on a Funkytown Hosting package, and Funkytown just so happens to offer a Remix adapter πŸŽ‰

First things first: you read the docs, and it looks like Funkytown maintains their own Remix adapter at https://github.com/funkytown4eva/remix-adapter and an example template at https://github.com/funkytown4eva/remix-template. This adapter is available on NPM at @funkytown/remix.

After familiarizing yourself with the Funkytown's hosting offerings, it's time to update our Hydrogen app to get it running on the blazingly-fast and fresh Funkytown hosting platform.

Step 1: Hello Funkytown

Let's dive in. Funkytown's docs tell us that we need to install the funkytown CLI in order to run Funkytown projects locally. We confirm this when we see the use of the funkytown in the package.json scripts listed in the Funkytown Remix template hosted on GitHub.

So let's install the CLI globally:

npm i -g funkytown

We also need to install the Funkytown Remix adapter into our local Hydrogen app:

npm i @funkytown/remix

Yay! Now, we need to make sure our local package.json#scripts are updated with the matching funkytown commands seen in the Funkytown Remix template:

{
  "scripts": {
    "dev": "funkytown dev",
    "build": "funkytown build",
    "start": "funkytown start"
  }
}

Step 2: Funkytown's entrypoint

Every hosting platform needs to have a single entrypoint into your app to be able to execute it. In other adapters, you might see a server.js or a worker.js file as the entrypoint. Remix also needs to know about this entrypoint so it can run and bundle your app.

Funkytown just so happens to have their Super Duper Build APIβ„’, and their CLI expects there to be a funkytown.js file as an entrypoint in order to hook into their build process.

Let's take a look at the funkytown.js file in the Funkytown Remix template:

import { createFunkytownHandler } from "@funkytown/remix";
import * as build from "@remix-run/dev/server-build";

const adapterMiddleware = async ({context}) => {
  // Place any middleware here
};

const handleRequest = createFunkytownHandler({
  build,
  mode: process.env.NODE_ENV,
  adapterMiddleware,
});

export function giveMeTheFunk(context) {
  return handleRequest(context);
}

Baller πŸ€

In this template, Funkytown is importing a function called createFunkytownHandler from the @funkytown/remix package. This function is a wrapper around the Remix handleRequest function, and it's what Funkytown uses to handle requests.

Funkytown's fancy build API expects its entrypoints to export a giveMeTheFunk function to handle requests, so that's that is exported from this file.

Tip: You'll want to be sure to reference the remix.config.js in the hosting runtime's example Remix template, and update your local remix.config.js to match.

But what's this adapterMiddleware function? This is a function that allows you to add middleware to your app. This is a great place to inject platform-specific things like environment variables, caching, and more.

Hold up: Middleware?!

Yes, Middleware. It is πŸ”₯, and it's a native feature in Remix.

Middleware lives in Remix apps in a couple different places with varying degrees of functionality:

  1. In the hosting adapter entrypoint. This allows you to inject context, modify the incoming request header, and modify the outgoing request.
  2. In individual routes. This includes the root.tsx file, and any other route files you create. This allows you to read and update context, but you CANNOT modify the outgoing response.

Hydrogen apps are powered by a storefrontClient that is injected into context in the root middleware. Let's take a look at the middleware in a boilerplate Hydrogen app which is designed to be hosted on Oxygen:

export async function middleware({context, request}: MiddlewareArgs) {
  const {waitUntil, cache, env} = context.get(oxygenContext);
  const session = await HydrogenSession.init(request, [env.SESSION_SECRET]);

  /**
   * Create Hydrogen's Storefront client.
   */
  const {storefront} = createStorefrontClient<I18nLocale>({
    cache,
    waitUntil,
    buyerIp: getBuyerIp(request),
    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,
    requestGroupId: request.headers.get('request-id'),
  });

  // Set helpful context for route loaders, actions, and middleware
  context.set(hydrogenContext, {
    storefront,
    session,
    waitUntil,
  });

  const response = await context.next();

  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 await storefrontRedirect({request, response, storefront});
  }

  return response;
}

Woof, that's a lot. But it's important!

Let's take a look at a few key things:

const {waitUntil, cache, env} = context.get(oxygenContext);

We pull waitUntil, cache and env objects out of a middleware context called oxygenContext.

As you might have guessed, these values are Oxygen-specific. This is necessary because Oxygen's hosting runtime injects utilities like cache (for caching) and env (which contains the app's environment variables set through the Oxygen UI) inside the hosting entrypoint (which happens to be server.js in this case).

We need some of these things in order to create the Hydrogen storefront client:

  • cache is used for the underlying Storefront API requests.
  • waitUntil is used to ensure we can populate the cache after the response has been returned.
  • env is used to access the Storefront API tokens and more.

So what are the Funkytown equivalent to these values?

  • Funkytown happens to provide a built-in cache object which adheres to the Cache API.
  • Funkytown doesn't require you to use waitUntil, so we can ignore that option.
  • Funkytown injects a context.myFunkyEnv object into the request handler which contains your environment variables that you've set using the funkytown env command (or through Funkytown's UI).

Tip: If your hosting runtime doesn't provide a Cache API out of the box, you can still run your Hydrogen app. Just know that sub-requests to the Storefront API will not be cached, regardless of whether you're applying cache headers.

OK, back to adapter middleware

We need to update our funkytown.js entrypoint to update the adapter middleware so that we inject the correct context into our Hydrogen app, so that we can read the cache and env values later on in the root middleware.

To start, we'll want to create a new MiddlewareContext for Funkytown. Let's create one in app/context.js and call it funkytownContext:

// app/context.js
import {createMiddlewareContext} from '@funkytown/remix';

export const funkytownContext = createMiddlewareContext();

Now, let's update our funkytown.js entrypoint to inject the funkytownContext into the adapterMiddleware function:

+import { funkytownContext } from '~/context';

const adapterMiddleware = async ({context}) => {
+ context.set(funkytownContext, {
+   cache: context.cache,
+   env: context.myFunkyEnv,
+ });
};

Cool. Now we can access the cache and env values in our root middleware:

export async function middleware({context, request}: MiddlewareArgs) {
- const {waitUntil, cache, env} = context.get(oxygenContext);
+ const {cache, env} = context.get(funkytownContext);
  const session = await HydrogenSession.init(request, [env.SESSION_SECRET]);

  /**
   * Create Hydrogen's Storefront client.
   */
  const {storefront} = createStorefrontClient<I18nLocale>({
    cache,
-   waitUntil,
    buyerIp: getBuyerIp(request),
    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,
    requestGroupId: request.headers.get('request-id'),
  });

  // Set helpful context for route loaders, actions, and middleware
  context.set(hydrogenContext, {
    storefront,
    session,
    waitUntil,
  });

  const response = await context.next();

  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 await storefrontRedirect({request, response, storefront});
  }

  return response;
}

OK, getting closer. But hold-up: what's the deal with getBuyerIp()?

On Buyer IPs

Shopify uses Buyer IP addresses to help mitigate rate limiting when proxying Storefront API requests across the server. It's important for you to provide your current buyer's IP address to the Storefront API so that you don't get rate limited. Read more about this here.

However, buyer or visitor IP addresses will be distributed differently based on your hosting runtime! Usually, it's included as a request header. Some hosts will provide it as x-forwarded-for. Others, like Oxygen, will provide it as a namespaced header for the hosting runtime like x-oxygen-buyer-ip.

OK, back to the root middleware

In our case, we check Funkytown's docs and find out that Funkytown injects a funky-monkey-ip header that contains the user's IP address. Let's use that, and ditch the Oxygen-specific getBuyerIp() function:

export async function middleware({context, request}: MiddlewareArgs) {
  const {cache, env} = context.get(funkytownContext);
  const session = await HydrogenSession.init(request, [env.SESSION_SECRET]);

  /**
   * Create Hydrogen's Storefront client.
   */
  const {storefront} = createStorefrontClient<I18nLocale>({
    cache,
-   buyerIp: getBuyerIp(request),
+   buyerIp: request.headers.get('funky-monkey-ip'),
    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,
    requestGroupId: request.headers.get('request-id'),
  });

  // Set helpful context for route loaders, actions, and middleware
  context.set(hydrogenContext, {
    storefront,
    session,
    waitUntil,
  });

  const response = await context.next();

  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 await storefrontRedirect({request, response, storefront});
  }

  return response;
}

Cool, cool. One more more thing to look at here:

requestGroupId: request.headers.get('request-id')

The request-id is a unique ID that Oxygen injects into every request for a Hydrogen app, and Hydrogen's storefront client uses it to "tag" each sub-request to the Storefront API. Shopify uses this information to be able to diagnose issues with a response to a Hydrogen app, like the number of sub-requests made, and more.

What does Funkytown provide in terms of unique Request ID? Funkytown docs to the rescue πŸš€ It looks like Funkytown injects a x-funky-request-id header that contains a unique ID for each request. Let's use that instead:

-requestGroupId: request.headers.get('request-id')
+requestGroupId: request.headers.get('x-funky-request-id')

Step 3: It's go time!

I think we're ready. By this point, you have:

  1. Installed Funkytown's Remix adapter and CLI tool
  2. Updated your package.json scripts and remix.config.js to use the same values Funkytown uses in their template
  3. Added a funkytown.js entrypoint to your app, including the adapterMiddleware function which injects Funkytown-specific context into your Hydrogen app
  4. Updated the root.tsx middleware to use the funkytownContext instead of the oxygenContext, and update the arguments to createStorefrontClient to use these values.

Let's give it a shot:

npm run dev

πŸ‘ Everything works!

Step 4: Bye-bye, Oxygen

Now that you've confirmed everything works in Funkytown, you're gonna want to remove Oxygen stuff from your local repository, including:

  • The @shopify/remix-oxygen dependency
  • The @shopify/cli-hydrogen dependency (it's only useful for Oxygen local development)
  • The old server.js entrypoint used by Oxygen
  • Any stray references to the oxygenContext used by middleware

Happy coding!

(BONUS) Step 5: New cache implementation: Custom Cache API

ring ring
YOU: "Hello?"
JANE: "Yeah, it's me, Jane, your CTO."
YOU: "What's up, Jane?"
JANE: "So, we just spun up a few Redis clusters across the globe, and I think you should use these for caching instead of Funkytown's built-in Cache APIs."
YOU: "Oh, yeah?"
JANE: "Yeah. We like to have control over our caching strategy, and we think we can net some performance wins by not being restricted to Funkytown's cache which is isolated to each region."
YOU: "On it! Jane, you're the best!"

OK, let's have some fun. We start by opening up our funkytown.js entrypoint and adding a new cache implementation:

import {redis} from 'some-3p-redis-client';

class FunkytownCache implements Cache {
  async function get(key: string) {
    // return await redis.get(key);
  }

  async function set(key: string, value: string) {
    // return await redis.set(key, value);
  }

  // ...
}

const cache = new FunkytownCache();

export async function adapterMiddleware({context, request}: MiddlewareArgs) {
  // ...
  context.set(funkytownContext, {
    cache,
    // ...
  });
}

Now your Hydrogen app uses the Redis-based cache instead!

(BONUS) Step 6: Another cache implementation: Monkey-patched fetch Cache API

More and more hosting runtimes are implementing a fetch-based cache by monkeypatching the fetch API. This works by setting additional information on the RequestInit object of fetch.

Let's pretend that Funkytown has implemented this cache API:

fetch('https://example.com', {
  funkytown: {
    cache: 'max-age=3600',
  }
});

Hydrogen allows you to hook into storefront.query to pass RequestInit values. To use this, you'll want to update all references to storefront.query to pass your new Funkytown cache directives:

await storefront.query(QUERY, VARIABLES, {
  init: {
    funkytown: {
      cache: 'max-age=3600',
    }
  }
})

Now, all sub-requests made to the Storefront API will be cached for 1 hour using Funkytown's fetch-based cache API!

@juanpprieto
Copy link

<3 this. It's crazy how much easier to follow this is than regular docs

@frandiox
Copy link

frandiox commented Mar 3, 2023

Amazing docs Josh πŸ”₯ πŸ˜‚

Loving the middleware so far πŸ’ͺ

Any stray references to the oxygenContext used by middleware

Perhaps we could refer to this as platformContext or runtimeContext so that it only needs to change in one place when changing adapters? Or better to be explicit?

Hydrogen allows you to hook into storefront.query to pass RequestInit values

We already allow headers and cache in storefront.query('...', {headers, cache}). Could we pass any extra option in that object to the fetch's init directly? Like storefront.query('...', {headers, cache, cf: {cacheTtl}}) (similar to fetch, and they can extend the global RequestInit type with custom stuff).
Or do you prefer to have this under {init: {...}}? In that case we might need to consider moving headers there as well.

The @shopify/cli-hydrogen dependency (it's only useful for Oxygen local development)

They can actually keep this CLI for utilities like: check standard routes; generate routes/components/tests; maybe more stuff in the future (upgrade versions, migration mods). The only Oxygen-specific commands are build/dev/preview.

context.set(hydrogenContext, {
storefront,
session,
waitUntil,
});

Kind of wish there was a "default context" so that utilities used very often like storefront could be accessed directly from loader/action parameters without an extra step to "get" the context πŸ€” -- perhaps a bad idea for many reasons tho πŸ˜…
Kind of reminds me to the exploration with did for H1 plugins where context could support symbols or strings, but it would fallback to 'default' if nothing was specified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment