A space to discuss GraphQL queries, mutations, troubleshooting, throttling, and best practices.
We're moving the community! Starting July 7, the current community will be read-only for approx. 2 weeks. You can browse content, but posting will be temporarily unavailable. Learn more
My Shopify app breaks after deploying to Heroku but ONLY when clicking the provided "install" link.
For example, when I click "Install App", this is the URL that is generated: https://shopify-app.herokuapp.com/?hmac=d61597ca3ea6ca74b8bd6ea8f8bcc812b4382fad6f15434c0158bc4c3ade...
Which skips that auth process and takes me to "This page does not exist."
So the flow is: "Click Install App" => "This page does not exist"
However, if I manually click the auth link: https://shopify-app.herokuapp.com/api/auth?shop=dev.myshopify.com, the app successfully completes the auth process and works without any issues.
I'm using the standard server code scaffolded from the Shopify CLI.
Commenting out this section in production allows the app to partially function:
if (isProd) { const compression = await import("compression").then( ({ default: fn }) => fn ); const serveStatic = await import("serve-static").then( ({ default: fn }) => fn ); app.use(compression()); app.use(serveStatic(PROD_INDEX_PATH)); }
Once the code is commented out, the generated install link is of the format: https://shopify-app.herokuapp.com/api/auth?shop=dev.myshopify.com. However, functionality throughout the app breaks upon clicking the install button.
The new flow is: "Click Install App" => "Begin Auth flow" => "Accept requested scopes" => "App breaks"
So essentially, the code snippet if(isProd) breaks the installation link while removing it breaks the rest of the app's functionality.
Really scratching my head on this one. Any ideas what's going on?
This is my server:
const USE_ONLINE_TOKENS = true; const TOP_LEVEL_OAUTH_COOKIE = "shopify_top_level_oauth"; // @ts-ignore const PORT = parseInt(process.env.BACKEND_PORT || process.env.PORT, 10); const isTest = process.env.NODE_ENV === "test" || !!process.env.VITE_TEST_BUILD; const versionFilePath = "./version.txt"; let templateVersion = "unknown"; if (fs.existsSync(versionFilePath)) { templateVersion = fs.readFileSync(versionFilePath, "utf8").trim(); } // TODO: There should be provided by env vars const DEV_INDEX_PATH = `${process.cwd()}/frontend/`; const PROD_INDEX_PATH = `${process.cwd()}/frontend/dist/`; const DB_PATH = `${process.cwd()}/database.sqlite`; Shopify.Context.initialize({ // @ts-ignore API_KEY: process.env.SHOPIFY_API_KEY, // @ts-ignore API_SECRET_KEY: process.env.SHOPIFY_API_SECRET, // @ts-ignore SCOPES: process.env.SCOPES.split(","), // @ts-ignore HOST_NAME: process.env.HOST.replace(/https?:\/\//, ""), // @ts-ignore HOST_SCHEME: process.env.HOST.split("://")[0], API_VERSION: ApiVersion.April22, IS_EMBEDDED_APP: true, // This should be replaced with your preferred storage strategy // SESSION_STORAGE: new Shopify.Session.SQLiteSessionStorage(DB_PATH), SESSION_STORAGE: new Shopify.Session.CustomSessionStorage( storeCallback, // @ts-ignore loadCallback, deleteCallback ), USER_AGENT_PREFIX: `Node App Template/${templateVersion}`, }); const ACTIVE_SHOPIFY_SHOPS = {}; Shopify.Webhooks.Registry.addHandler("APP_UNINSTALLED", { path: "/api/webhooks", webhookHandler: async (topic, shop, body) => { // @ts-ignore delete ACTIVE_SHOPIFY_SHOPS[shop], //Delete unsubscribed shop and clean undefined entries console.log("APP UNINSTALLED"); await pool.query( `DELETE FROM shop WHERE shop_url=$1 OR shop_url='undefined' OR shop_url='' OR shop_url IS NULL`, [shop] ); }, }); setupGDPRWebHooks("/api/webhooks"); // export for test use only export async function createServer( root = process.cwd(), isProd = process.env.NODE_ENV === "production", billingSettings = BILLING_SETTINGS) { const app = express(); app.set("top-level-oauth-cookie", TOP_LEVEL_OAUTH_COOKIE); app.set("active-shopify-shops", ACTIVE_SHOPIFY_SHOPS); app.set("use-online-tokens", USE_ONLINE_TOKENS); app.use(cookieParser(Shopify.Context.API_SECRET_KEY)); applyAuthMiddleware(app, { billing: billingSettings, }); app.post("/api/webhooks", async (req, res) => { try { await Shopify.Webhooks.Registry.process(req, res); console.log(`Webhook processed, returned status code 200`); } catch (error) { console.log(`Failed to process webhook: ${error}`); if (!res.headersSent) { res.status(500).send(error.message); } } }); app.use(bodyParser.json()); // All endpoints after this point will require an active session app.use( "/api/*", verifyRequest(app, { // @ts-ignore billing: billingSettings, }) ); //app.use("/api/test", test); app.use("/api/sort-options", sortOptions); app.use("/api/sort-logic", sortLogic); app.get("/api/products-count", async (req, res) => { const session = await Shopify.Utils.loadCurrentSession(req, res, true); const { Product } = await import( `@shopify/shopify-api/dist/rest-resources/${Shopify.Context.API_VERSION}/index.js` ); const countData = await Product.count({ session }); res.status(200).send(countData); }); app.post("/api/graphql", async (req, res) => { try { const response = await Shopify.Utils.graphqlProxy(req, res); res.status(200).send(response.body); } catch (error) { res.status(500).send(error.message); } }); app.use(express.json()); app.use((req, res, next) => { const shop = req.query.shop; if (Shopify.Context.IS_EMBEDDED_APP && shop) { res.setHeader( "Content-Security-Policy", `frame-ancestors https://${shop} https://admin.shopify.com;` ); } else { res.setHeader("Content-Security-Policy", `frame-ancestors 'none';`); } next(); }); if (isProd) { const compression = await import("compression").then( ({ default: fn }) => fn ); const serveStatic = await import("serve-static").then( ({ default: fn }) => fn ); app.use(compression()); app.use(serveStatic(PROD_INDEX_PATH)); console.log(`Serving static files from ${PROD_INDEX_PATH}`); } app.use("/*", async (req, res, next) => { const shop = req.query.shop; console.log("THIS IS THE CATCHALL ROUTE") // //CHECK TO MAKE SURE SCOPE EXISTS AND ISN'T UNDEFINED, OFFLINE SHOPS? const shopValue = await pool.query(`SELECT * FROM shop WHERE shop_url=$1`, [ shop, ]); if (shopValue?.rows[0]?.scope) { ACTIVE_SHOPIFY_SHOPS[shop] = shopValue.rows[0].scope; } else { ACTIVE_SHOPIFY_SHOPS[shop] = undefined; } // Detect whether we need to reinstall the app, any request from Shopify will // include a shop in the query parameters. // @ts-ignore if (app.get("active-shopify-shops")[shop] === undefined) { res.redirect(`/api/auth?shop=${shop}`); } else { // res.set('X-Shopify-App-Nothing-To-See-Here', '1'); const fs = await import("fs"); console.log(`Serving static files from ${DEV_INDEX_PATH}`); const fallbackFile = join( isProd ? PROD_INDEX_PATH : DEV_INDEX_PATH, "index.html" ); res .status(200) .set("Content-Type", "text/html") .send(fs.readFileSync(fallbackFile)); } }); return { app }; }
Same issue here. App rejected because of this
This same issue was driving me crazy and I think I finally found a solution. I basically just split up the code in the "app.use("/*", async (req, res, next) => {.....}" chunk into two separate pieces and put the compression and serveStatic stuff in between. You should be able to easily insert your own code to check if the app is installed in my snippet below. Hope this helps. I'm using Node 16 instead of 18, but I don't think that should matter.
app.use("/*", async (req, res, next) => {
try {
const shop = req.query.shop;
// Detect whether we need to reinstall the app, any request from Shopify will
// include a shop in the query parameters.
// YOUR LOGIC TO CHECK IF APP IS INSTALLED HERE
const appInstalled = [true | false];
if (!appInstalled && shop) {
res.redirect(`/api/auth?shop=${shop}`);
} else {
next();
}
} catch (error) {
console.error(error);
Sentry.captureException(error);
res.status(500).send("unexpected error has occured");
}
});
if (isProd) {
const compression = await import("compression").then(
({ default: fn }) => fn
);
const serveStatic = await import("serve-static").then(
({ default: fn }) => fn
);
app.use(compression());
app.use(serveStatic(PROD_INDEX_PATH));
}
app.use("/*", async (req, res, next) => {
try {
// res.set('X-Shopify-App-Nothing-To-See-Here', '1');
const fs = await import("fs");
const fallbackFile = join(
isProd ? PROD_INDEX_PATH : DEV_INDEX_PATH,
"index.html"
);
res
.status(200)
.set("Content-Type", "text/html")
.send(fs.readFileSync(fallbackFile));
} catch (error) {
console.error(error);
Sentry.captureException(error);
res.status(500).send("unexpected error has occured");
}
});
I
The issue is with the following line:
app.use(serveStatic(PROD_INDEX_PATH));
The serveStatic call is intercepting the first request to / and defaulting it to /index.html, which causes it to skip OAuth even though the app isn't installed yet. You can fix that by changing that line to:
app.use(serveStatic(PROD_INDEX_PATH, { index: false }));