shopify-app-template-node
shopify-app-template-node copied to clipboard
CLI Bug - Install Link Does Not Work in Production
Issue summary
An app built with the Shopify CLI and deployed to Heroku or Fly.io is not able to be installed onto a store using the installation link. This causes the app to fail the review process.
However, the CLI app code will work when manually clicking the auth link directly.
To test this, I scaffolded a fresh project from the CLI and deployed directly to Heroku.
This issue only occurs on installation and I believe app.use(serveStatic(PROD_INDEX_PATH));
is the root cause.
If the app is installed through the auth link directly, then app.use(serveStatic(PROD_INDEX_PATH));
works and properly serves data without any issues.
Reduced test case
See this video demonstration of this issue: https://www.loom.com/share/b879ed56e4124736bdc10e5c954907f0
@paulomarg - any idea what's going on?
Server code:
// @ts-check
import { join } from "path";
import fs from "fs";
import express from "express";
import cookieParser from "cookie-parser";
import { Shopify, ApiVersion } from "@shopify/shopify-api";
import applyAuthMiddleware from "./middleware/auth.js";
import verifyRequest from "./middleware/verify-request.js";
import { setupGDPRWebHooks } from "./gdpr.js";
import { BillingInterval } from "./helpers/ensure-billing.js";
const USE_ONLINE_TOKENS = true;
const TOP_LEVEL_OAUTH_COOKIE = "shopify_top_level_oauth";
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({
API_KEY: process.env.SHOPIFY_API_KEY,
API_SECRET_KEY: process.env.SHOPIFY_API_SECRET,
SCOPES: process.env.SCOPES.split(","),
HOST_NAME: process.env.HOST.replace(/https?:\/\//, ""),
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),
USER_AGENT_PREFIX: `Node App Template/${templateVersion}`,
});
// Storing the currently active shops in memory will force them to re-login when your server restarts. You should
// persist this object in your app.
const ACTIVE_SHOPIFY_SHOPS = {};
Shopify.Webhooks.Registry.addHandler("APP_UNINSTALLED", {
path: "/api/webhooks",
webhookHandler: async (topic, shop, body) =>
delete ACTIVE_SHOPIFY_SHOPS[shop],
});
// The transactions with Shopify will always be marked as test transactions, unless NODE_ENV is production.
// See the ensureBilling helper to learn more about billing in this template.
const BILLING_SETTINGS = {
required: false,
// This is an example configuration that would do a one-time charge for $5 (only USD is currently supported)
// chargeName: "My Shopify One-Time Charge",
// amount: 5.0,
// currencyCode: "USD",
// interval: BillingInterval.OneTime,
};
// This sets up the mandatory GDPR webhooks. You’ll need to fill in the endpoint
// in the “GDPR mandatory webhooks” section in the “App setup” tab, and customize
// the code when you store customer data.
//
// More details can be found on shopify.dev:
// https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks
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);
}
}
});
// All endpoints after this point will require an active session
app.use(
"/api/*",
verifyRequest(app, {
billing: billingSettings,
})
);
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));
}
app.use("/*", async (req, res, next) => {
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.
if (app.get("active-shopify-shops")[shop] === undefined && shop) {
res.redirect(`/api/auth?shop=${shop}`);
} else {
// 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));
}
});
return { app };
}
if (!isTest) {
createServer().then(({ app }) => app.listen(PORT));
}
@PurplePineapple123 Were you able to resolve this? I'm facing the same issue.
@v-octal I did manage to fix it. So the branch the CLI automatically pulled from was cli_three.
That has the above bug and doesn't work in production. I created a new project with this repos main
branch and then rebuilt my project and deployed. That fixed the issue for me.
I don't really want to move to CLI 2.0 as it would require quite a lot of effort to rebuild and test the app again. I hope there's some other way around it.
Honestly, I was the same way and then spent probably 20 hours on this bug. For me it got to a point where it made more sense to bite the bullet and rebuild. I believe the issue has something to do with app.use(serveStatic(PROD_INDEX_PATH));
. Just not sure what
Hey folks, thanks for reporting this. I think you're right @PurplePineapple123 - 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 }));
I'll put up a PR that does the same so future apps won't run into it. Great catch!
Thank you @paulomarg. This worked!
This issue is stale because it has been open for 90 days with no activity. It will be closed if no further action occurs in 14 days.
Hey folks, thanks for reporting this. I think you're right @PurplePineapple123 - 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 }));
I'll put up a PR that does the same so future apps won't run into it. Great catch!
Well it takes me days to find your solution, thank you so much!
This issue is stale because it has been open for 60 days with no activity. It will be closed if no further action occurs in 14 days.
Closing this one as it is now solved
Sorry to dig this one from the archives but I wonder if the issue I'm having is related to this.
I've been working on app and was able to successfully install it on a development store, and then on a production store using the merchant install link. That link was still pointing to a development version of this app, served via ngrok. Once I created a new copy of the very same app intended to go into production, trying to run the corresponding merchant install link just gives me the error "You don't have this app installed": https://share.cleanshot.com/9cyPS8FZ
Clicking the "Install" button just pushes me back to the same page.
I can see the ensureInstalledOnShop
middleware showing up on the logs, but nothing past that.
The very same app, when using the "Test your app" functionality (that only can run on a development store), works as expected and the app operates normally over the same server.
I've tried everything I could remember, still can get past this one. Any pointers would be much appreciated.
@cquemin I've meant to follow up on my comment earlier and forgot, sorry! I was going to say that in my case the issue appear to be the same as outlined above but it actually was not. In my case it was due to an erroneous session being present in the database, causing the app to think it was already installed while it was not yet. This would halt the activation process and give that misleading message. Once I've removed the erroneous entry from the database things started working as expected.
@lmartins Thanks for following up, I wanted to have a look today but I am happy to hear this was different. I will close that one then. Thanks a lot for taking the time to share and to follow up with a detailed explanation!