NodeJS express template infinite load

Since yesterday we have ran into issues when trying to install a new app using the NodeJS express template provided by Shopify (found here)

It gets stuck on a blank screen inside of the embedded app UI. And it does not seem like any records are being added to our Postgress database inside of the shopify_sessions table.

We have not changed anything since this behavior started.

It seems to only be an issue when in a development environment, as we currently have multiple public apps in production using this template that has no issues installing on new stores.

Screenshots and information is a bit sparse since Iโ€™m not even sure what to share. Iโ€™m happy to provide more details if needed.

These posts seems to relate to this very problem.

Hi Magnus,

Are you seeing any errors in the console?

No, there are no errors anywhere which seems odd. Also it seems to only be an issue if the shop isnโ€™t already authenticated and stored in the database.

If we go to the generated auth url directly in the browser it authenticates and creates the shop in the database and redirects to the embedded app fine. From there on out there doesnโ€™t seem to be any issues.

Again this issue only started happening very recently, and the overall behavior seems quite on/off.

But manually entering the auth url in the browser seems to be the only reliable way to resolve this issue for us, for now.

Also one of our developers think it may be something with the exitiframe component. It seems like the window.open call that redirects to the auth url doesnโ€™t actually trigger the redirect. But thatโ€™s only a hypothesis

Here is a snippet of that code:

useEffect(() => {
    if (!!app && !!search) {
      const params = new URLSearchParams(search)
      const redirectUri = params.get('redirectUri')

      if (redirectUri) {
        try {
          const url = new URL(decodeURIComponent(redirectUri))

          if (
            [location.hostname, 'admin.shopify.com'].includes(url.hostname) ||
            url.hostname.endsWith('.myshopify.com')
          ) {
            window.open(url.toString(), '_top')
          } else {
            setShowWarning(true)
          }
        } catch (error) {
          console.error('Invalid redirectUri:', error)
          setShowWarning(true)
        }
      } else {
        console.error('No redirectUri provided in query string')
        setShowWarning(true)
      }
    }
  }, [app, search, setShowWarning])

@Magnus_Fischer

This template is very old. It hasnโ€™t been updated in over a year.

The exit frame method is pre-managed installations, when the OAuth flow needed to be faciliated by the individual apps.

Now Shopify has managed installations which take care of the OAuth handshake end-to-end and you can just worry about session token auth on your API routes.

I highly recommend checking out managed installs so you can bypass the exitiframe issues: Enable Shopify-managed installations for your app

After youโ€™ve deployed your shopify.app.toml, just update your callback route to go directly to your appโ€™s homepage instead of a dedicated OAuth start route.

We are using managed installations, the exitiframe components comes from the latest frontend template.

Iโ€™m not sure Iโ€™m fully understanding this part.

I see. The ExpressJS template uses this React template under the hood.

I wonder if the managed installation isnโ€™t supported by this frontend template. You can revert back to the unmanaged installs by adding this config to your shopify.app.toml:

[access_scopes]
scopes = "read_products"

# disable managed installs
use_legacy_install_flow = true

Donโ€™t forget to deploy this change to your development app to disable managed installations and see if that does the trick for you.

Hi Magnus,

Iโ€™ve connected with our internal team to dig into this issue to see why itโ€™s occurring. Will update here when I learn more.

1 Like

Hi @Magnus_Fischer

I was wondering if you could help me clarify your steps for reproduction.

If we go to the generated auth url directly in the browser it authenticates and creates the shop in the database and redirects to the embedded app fine. From there on out there doesnโ€™t seem to be any issues.

Could you clarify how you are trying to access the app, that causes the infinite load? i.e. How are you trying to install the app that causes this? Installing from the partner dashboard? CLI link? Shopify Admin?

We are installing the app through the CLI preview link. Iโ€™m pretty sure that is the only way we have been installing our development apps.

We are running CLI v3.76.2

Sorry to clarify

You were seeing the problem when you would click on the CLI preview link in the in your terminal, but if you were to paste that same link in the browser directly it would work?

Or am I misunderstanding?

Ah sorry for the confusion, hope this clarifies it:

So the URL provided by the CLI looks like this:

https://jacobsenplus-test.myshopify.com/admin/oauth/redirect_from_cli?client_id=bc4152674e3ec[...redacted]

But the actual auth url that the ExitIframe component tries to open with the window.open(url.toString()) call looks like this:

https://[mytunnel].trycloudflare.com/api/auth?shop=jacobsenplus-test.myshopify.com&host=YWRtaW4uc2hvcGlmeS5jb20v[...redacted]

If we enter the cloudflare tunnel URL directly in the browser it works, this is the flow:

  • โ†’ Enter the cloudflare url directly into our browser
  • โ†’ The app now authenticates properly
  • โ†’ The shopify_session record gets added to our database
  • โ†’ Browser redirects us back to the embedded app UI
  • โ†’ The app now loads fine

If we enter through the CLI preview link:

  • โ†’ Shopify asks us to confirm the install
  • โ†’ Throws us into the Embedded APP UI with a blank screen
  • โ†’ Gets stuck

Also worth noting that for a successful install, we start by installing through the CLI preview link and going through the 3 steps described above, then we console.log() the url inside of the ExitIframe component and copy that URL into our browser. And to reiterate; this seems to only be an issue when doing a first time install for a shop in a development environment. Our production environments deployed to Heroku all install fine on new stores.

Thank you for clarifying!

Could you also share your logs from your terminal when you turn on debug level logging?

const shopify = shopifyApp({
  api: {
    apiVersion: LATEST_API_VERSION,
    restResources,
    future: {
      customerAddressDefaultFix: true,
      lineItemBilling: true,
      unstable_managedPricingSupport: true,
    },
    logger: {
      level: 3, // debug level logging
    },
20:18:48 โ”‚  web-backend โ”‚ /Users/magnusfischer/Github/app-test/node_modules/@shopify/shopify-app-express/build/cjs/middlewa
res/ensure-installed-on-shop.js:16
20:18:48 โ”‚  web-backend โ”‚       config.logger.info('Running ensureInstalledOnShop');
20:18:48 โ”‚  web-backend โ”‚                     ^
20:18:48 โ”‚  web-backend โ”‚
20:18:48 โ”‚  web-backend โ”‚ TypeError: config.logger.info is not a function
20:18:48 โ”‚  web-backend โ”‚     at /Users/magnusfischer/Github/app-test/node_modules/@shopify/shopify-app-express/build/cjs/m
iddlewares/ensure-installed-on-shop.js:16:21
20:18:48 โ”‚  web-backend โ”‚     at Layer.handle [as handle_request]
(/Users/magnusfischer/Github/app-test/node_modules/express/lib/router/layer.js:95:5)
20:18:48 โ”‚  web-backend โ”‚     at trim_prefix
(/Users/magnusfischer/Github/app-test/node_modules/express/lib/router/index.js:328:13)
20:18:48 โ”‚  web-backend โ”‚     at /Users/magnusfischer/Github/app-test/node_modules/express/lib/router/index.js:286:9
20:18:48 โ”‚  web-backend โ”‚     at param
(/Users/magnusfischer/Github/app-test/node_modules/express/lib/router/index.js:365:14)
20:18:48 โ”‚  web-backend โ”‚     at param
(/Users/magnusfischer/Github/app-test/node_modules/express/lib/router/index.js:376:14)
20:18:48 โ”‚  web-backend โ”‚     at Function.process_params
(/Users/magnusfischer/Github/app-test/node_modules/express/lib/router/index.js:421:3)
20:18:48 โ”‚  web-backend โ”‚     at next
(/Users/magnusfischer/Github/app-test/node_modules/express/lib/router/index.js:280:10)
20:18:48 โ”‚  web-backend โ”‚     at SendStream.error
(/Users/magnusfischer/Github/app-test/node_modules/serve-static/index.js:121:7)
20:18:48 โ”‚  web-backend โ”‚     at SendStream.emit (node:events:519:28)
20:18:48 โ”‚  web-backend โ”‚     at SendStream.emit (node:domain:488:12)
20:18:48 โ”‚  web-backend โ”‚     at SendStream.error (/Users/magnusfischer/Github/app-test/node_modules/send/index.js:270:17)
20:18:48 โ”‚  web-backend โ”‚     at SendStream.onStatError
(/Users/magnusfischer/Github/app-test/node_modules/send/index.js:417:12)
20:18:48 โ”‚  web-backend โ”‚     at next (/Users/magnusfischer/Github/app-test/node_modules/send/index.js:759:28)
20:18:48 โ”‚  web-backend โ”‚     at /Users/magnusfischer/Github/app-test/node_modules/send/index.js:767:23
20:18:48 โ”‚  web-backend โ”‚     at FSReqCallback.oncomplete (node:fs:197:21)
20:18:48 โ”‚  web-backend โ”‚
20:18:48 โ”‚  web-backend โ”‚ Node.js v20.16.0

Then if I refresh the browser

20:18:48 โ”‚ web-frontend โ”‚ 8:18:48 PM [vite] http proxy error: /?embedded=1&hmac=bf783483d987d66[...redacted]&host=YWRtaW4uc2h[...redacted]&id_token=eyJhbGciOiJIUzI1NiI[...redacted]&locale=en&session=709099[...redacted]&shop=jacobsenplus-test.myshopify.com&timestamp=1742498326
20:18:48 โ”‚ web-frontend โ”‚ Error: socket hang up
20:18:48 โ”‚ web-frontend โ”‚     at Socket.socketOnEnd (node:_http_client:524:23)
20:18:48 โ”‚ web-frontend โ”‚     at Socket.emit (node:events:531:35)
20:18:48 โ”‚ web-frontend โ”‚     at endReadableNT (node:internal/streams/readable:1696:12)
20:18:48 โ”‚ web-frontend โ”‚     at process.processTicksAndRejections (node:internal/process/task_queues:82:21)

Sorry for all the questions, but I canโ€™t reproduce this on my end, so the more context the easier it is to look in to.

Could you include the logs that happen before what you included?
e.g. before this?

20:18:48 โ”‚  web-backend โ”‚ /Users/magnusfischer/Github/app-test/node_modules/@shopify/shopify-app-express/build/cjs/middlewa
res/ensure-installed-on-shop.js:16
20:18:48 โ”‚  web-backend โ”‚       config.logger.info('Running ensureInstalledOnShop');
20:18:48 โ”‚  web-backend โ”‚                     ^
20:18:48 โ”‚  web-backend โ”‚
โ•ญโ”€ info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚                                                                                โ”‚
โ”‚  Using shopify.app.toml for default values:                                    โ”‚
โ”‚                                                                                โ”‚
โ”‚    โ€ข Org:             D.TAILS                                                  โ”‚
โ”‚    โ€ข App:             magnus-app-test                                          โ”‚
โ”‚    โ€ข Dev store:       jacobsenplus-test.myshopify.com                          โ”‚
โ”‚    โ€ข Update URLs:     Yes                                                      โ”‚
โ”‚                                                                                โ”‚
โ”‚   You can pass `--reset` to your command to reset your app configuration.      โ”‚
โ”‚                                                                                โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

20:18:26 โ”‚     graphiql โ”‚ GraphiQL server started on port 3457
20:18:26 โ”‚ web-frontend โ”‚
20:18:26 โ”‚ web-frontend โ”‚ > shopify-app-template-frontend@2.0.0 dev
20:18:26 โ”‚ web-frontend โ”‚ > vite
20:18:26 โ”‚ web-frontend โ”‚
20:18:26 โ”‚  web-backend โ”‚
20:18:26 โ”‚  web-backend โ”‚ > shopiy-app-template-backend@1.0.0 dev
20:18:26 โ”‚  web-backend โ”‚ > cross-env NODE_ENV=development nodemon index.js --ignore ./frontend
20:18:26 โ”‚  web-backend โ”‚
20:18:27 โ”‚  web-backend โ”‚ [nodemon] 2.0.22
20:18:27 โ”‚  web-backend โ”‚ [nodemon] to restart at any time, enter `rs`
20:18:27 โ”‚  web-backend โ”‚ [nodemon] watching path(s): *.*
20:18:27 โ”‚  web-backend โ”‚ [nodemon] watching extensions: js,mjs,json
20:18:27 โ”‚  web-backend โ”‚ [nodemon] starting `node index.js`
20:18:27 โ”‚ web-frontend โ”‚
20:18:27 โ”‚ web-frontend โ”‚   VITE v6.2.1  ready in 106 ms
20:18:27 โ”‚ web-frontend โ”‚
20:18:27 โ”‚ web-frontend โ”‚   โžœ  Local:   http://localhost:49984/
20:18:27 โ”‚  web-backend โ”‚ .env.development does not exist, fallback to .env
20:18:27 โ”‚  web-backend โ”‚ [shopify-api/INFO] version 7.7.0, environment Node v20.16.0
20:18:27 โ”‚  web-backend โ”‚ Server is running at https://[mytunnel].trycloudflare.com
20:18:27 โ”‚  web-backend โ”‚ [shopify-api/INFO] Detected multiple handlers for 'APP_UNINSTALLED', webhooks.process will call
them sequentially
20:18:27 โ”‚  web-backend โ”‚ Sentry error handling disabled
20:18:27 โ”‚  web-backend โ”‚ (node:43315) Warning: Accessing non-existent property 'padLevels' of module exports inside
circular dependency
20:18:27 โ”‚  web-backend โ”‚ (Use `node --trace-warnings ...` to show where the warning was created)

Long shot but ages ago when I had this issue this is what I did.

import { useEffect } from "react";
import {
  Card,
  Layout,
  Page,
  Spinner,
  Text,
  BlockStack,
} from "@shopify/polaris";

const ExitFrame = () => {
  useEffect(() => {
    if (typeof window !== "undefined") {
      const shop = window?.shopify?.config?.shop;
      open(`https://${appOrigin}/api/auth?shop=${shop}`, "_top"); //appOrigin is my SHOPIFY_APP_URL
    }
  }, []);

  return (
    <>
      <Page>
        <Layout>
          <Layout.Section>
            <Card>
              <BlockStack gap="200">
                <Text variant="headingMd">Security Checkpoint</Text>
                <Text variant="bodyMd">Reauthorizing your tokens</Text>
                <Spinner />
              </BlockStack>
            </Card>
          </Layout.Section>
        </Layout>
      </Page>
    </>
  );
};

export default ExitFrame;

Now as far as redirect URL is concerned, this is what my auth looked like:

import {
  BotActivityDetected,
  CookieNotFound,
  InvalidOAuthError,
  InvalidSession,
} from "@shopify/shopify-api";
import StoreModel from "../../utils/models/StoreModel.js";
import sessionHandler from "../../utils/sessionHandler.js";
import shopify from "../../utils/shopify.js";

const authMiddleware = (app) => {
  app.get("/api/auth", async (req, res) => {
    try {
      if (!req.query.shop) {
        return res.status(500).send("No shop provided");
      }

      if (req.query.embedded === "1") {
        const shop = shopify.utils.sanitizeShop(req.query.shop);
        const queryParams = new URLSearchParams({
          ...req.query,
          shop,
          redirectUri: `https://${shopify.config.hostName}/api/auth?shop=${shop}`,
        }).toString();

        return res.redirect(`/exitframe?${queryParams}`);
      }

      return await shopify.auth.begin({
        shop: req.query.shop,
        callbackPath: "/api/auth/tokens",
        isOnline: false,
        rawRequest: req,
        rawResponse: res,
      });
    } catch (e) {
      console.error(`---> Error at /api/auth`, e);
      const { shop } = req.query;
      switch (true) {
        case e instanceof CookieNotFound:
        case e instanceof InvalidOAuthError:
        case e instanceof InvalidSession:
          res.redirect(`/api/auth?shop=${shop}`);
          break;
        case e instanceof BotActivityDetected:
          res.status(410).send(e.message);
          break;
        default:
          res.status(500).send(e.message);
          break;
      }
    }
  });

  app.get("/api/auth/tokens", async (req, res) => {
    try {
      const callbackResponse = await shopify.auth.callback({
        rawRequest: req,
        rawResponse: res,
      });

      const { session } = callbackResponse;

      await sessionHandler.storeSession(session);

      const webhookRegisterResponse = await shopify.webhooks.register({
        session,
      });
      console.dir(webhookRegisterResponse, { depth: null });

      return await shopify.auth.begin({
        shop: session.shop,
        callbackPath: "/api/auth/callback",
        isOnline: true,
        rawRequest: req,
        rawResponse: res,
      });
    } catch (e) {
      console.error(`---> Error at /api/auth/tokens`, e);
      const { shop } = req.query;
      switch (true) {
        case e instanceof CookieNotFound:
        case e instanceof InvalidOAuthError:
        case e instanceof InvalidSession:
          res.redirect(`/api/auth?shop=${shop}`);
          break;
        case e instanceof BotActivityDetected:
          res.status(410).send(e.message);
          break;
        default:
          res.status(500).send(e.message);
          break;
      }
    }
  });

  app.get("/api/auth/callback", async (req, res) => {
    try {
      const callbackResponse = await shopify.auth.callback({
        rawRequest: req,
        rawResponse: res,
      });

      const { session } = callbackResponse;
      await sessionHandler.storeSession(session);

      const host = req.query.host;
      const { shop } = session;

      await StoreModel.findOneAndUpdate(
        { shop },
        { isActive: true },
        { upsert: true }
      ); //Update store to true after auth has happened, or it'll cause reinstall issues.

      return res.redirect(`/?shop=${shop}`);
    } catch (e) {
      console.error(`---> Error at /api/auth/callback`, e);
      const { shop } = req.query;
      switch (true) {
        case e instanceof CookieNotFound:
        case e instanceof InvalidOAuthError:
        case e instanceof InvalidSession:
          res.redirect(`/api/auth?shop=${shop}`);
          break;
        case e instanceof BotActivityDetected:
          res.status(410).send(e.message);
          break;
        default:
          res.status(500).send(e.message);
          break;
      }
    }
  });
};

export default authMiddleware;

The idea here is if you redirect merchant to yourappurl.com/?shop=storename.myshopify.com, AppBridge will handle the redirection to your app and without having to worry about a proper redirection.

Though hereโ€™s another thing - @Dylan is correct that you should migrate away from the older OAuth into Managed Installation thatโ€™s much more elegant with this. Just like he said, add this to your shopify.app.toml file. This means you can remove all of your /api/auth/* routes and have your auth handled by Shopify.

# false -> use managed installation
use_legacy_install_flow = false

To do auth with managed installation, in your server/index.js you want to add a new middleware, mine is called isInitialLoad and looks like this:

import { RequestedTokenType } from "@shopify/shopify-api";
import StoreModel from "../../utils/models/StoreModel.js";
import sessionHandler from "../../utils/sessionHandler.js";
import shopify from "../../utils/shopify.js";
import freshInstall from "../../utils/freshInstall.js";

/**
 * @param {import('express').Request} req - Express request object
 * @param {import('express').Response} res - Express response object
 * @param {import('express').NextFunction} next - Express next middleware function
 */
const isInitialLoad = async (req, res, next) => {
  try {
    const shop = req.query.shop;
    const idToken = req.query.id_token;

    if (shop && idToken) {
      const { session: offlineSession } = await shopify.auth.tokenExchange({
        sessionToken: idToken,
        shop,
        requestedTokenType: RequestedTokenType.OfflineAccessToken,
      });
      const { session: onlineSession } = await shopify.auth.tokenExchange({
        sessionToken: idToken,
        shop,
        requestedTokenType: RequestedTokenType.OnlineAccessToken,
      });

      await sessionHandler.storeSession(offlineSession);
      await sessionHandler.storeSession(onlineSession);

      const webhookRegistrar = await shopify.webhooks.register({
        session: offlineSession,
      });

      const isFreshInstall = await StoreModel.findOne({
        shop: onlineSession.shop,
      });

      if (!isFreshInstall || isFreshInstall?.isActive === false) {
        // !isFreshInstall -> New Install
        // isFreshInstall?.isActive === false -> Reinstall
        await freshInstall({ shop: onlineSession.shop });
      }

      console.dir(webhookRegistrar, { depth: null });
    }
    next();
  } catch (e) {
    console.error(`---> An error occured in isInitialLoad`, e);
    return res.status(403).send({ error: true });
  }
};

export default isInitialLoad;

The idea here is with managed installation you want to swap tokens with Shopify to get Online and Offline session tokens and store it in your database.

1 Like

Thanks so much for your post, @kinngh!

This is really helpful, Iโ€™ll pass it along to our backend team to check out. I really appreciate you taking the time to write such a detailed response!

Cheers!

1 Like