CRON jobs in Remix app ? How to have separate server and client ?

noiseymur
Shopify Partner
20 2 8

Back then Shopify app template used to have separate client and server folders and we could then join two of them with the API calls. Now that the template is built using Remix, I want to know hot to achieve the old structure while still using Remix. For example I think CRON jobs are only possible with a separate server file, not inside Remix routes.

Replies 11 (11)

michaelg47
Shopify Partner
7 0 3

Hey, did you ever find a solution you could share?

noiseymur
Shopify Partner
20 2 8

Hey. Unfortunately I couldn't find a way. It's possible to add your own server to a Remix project. There's a short example in the Remix documentation for that. I tried several times to add a separate server to the Shopify Remix app template using this, it kind of worked, but still I couldn't move the Shopify api instance, authorization and all the stuff to backend, so I gave up for now. Waiting for updates to the documentation from Shopify team.

michaelg47
Shopify Partner
7 0 3

I'm using Defer now and it has worked consistently so far. Easy to set up too. https://docs.defer.run/introduction

rm-philmells
Shopify Partner
6 0 3

Hi,

 

We are quite new to Shopify Apps (and the Remix stack) and we're just looking to build some internal apps to automate some specific things. Not asking for detailed instructions but could you give any pointers about getting started with Defer and Shopify remix? Defer's service looks like what we're looking for but we're just picking up React/Remix (having come from a predominantly Vue background on other platforms) and Shopify's documentation is somewhat lacking around specific topics (such as authentication in a Remix app when it's not being accessed by a user).

Charles_Roberts
Shopify Partner
44 0 8

That looks like a ‘NO’ then, from the Shopify Remix core team. I mean does Shopify actually want to develop a thriving community or not?

Please give us some pointers please, Shopify. 
Maybe, we need to create a specific route,  called app.cron.jsx and then add a call to one of the defer() methods, in the /defer folder, from the remix loader() method? 

Charles_Roberts
Shopify Partner
44 0 8
Charles_Roberts
Shopify Partner
44 0 8

But unfortunately, the Defer platform is shutting down in May 2024. 🤷‍♀️

rm-philmells
Shopify Partner
6 0 3

We're actually going to be taking a look at https://gadget.dev/ to handle this stuff as they apparently have scheduling baked into their platform. It's a bit more expensive that just running with Fly for hosting but the lack of support for scheduling using the Remix boilerplate and the hassle of getting it working is just not worth it for us.

Charles_Roberts
Shopify Partner
44 0 8

I am actually using node-cron in my Remix action() method.

It works perfectly...

 

let job = {};

export async function loader({ request, params }) {

  const { admin, session } = await authenticate.admin(request);
  const { shop } = session;
 
  return json({
    ownerKey: '',
    action: '',
    error: ''
  });

}

export async function action({ request, params }) {

  const { admin, session } = await authenticate.admin(request);
  const { shop } = session;

  const formData$ = await request.formData();
  const formData = Object.fromEntries(formData$);

  const data = {
    ...formData
  };

  const errors = validateCronForm(data);

  if (errors) {
    return json({ errors }, { status: 422 });
  }

  const action = formData.action;
  let cronResponse = {};
  let error = '';

  if(formData.ownerKey === process.env.OWNER_KEY){
    cronResponse = createCron(action, shop, process);
    if('error' in cronResponse){
      error = 'Task threw an error';
    }
  }
  else{
    error = 'Owner key did not match';
  }

  return json({
    responseData: {
      ownerKey: formData.ownerKey,
      action: formData.action === 'start' ? 'started' : 'stopped',
      error
    },
    errors
  });
}

function createCron(action, shop, process){

  const cron = require('node-cron');
  const cronTime = process.env.ENVIRONMENT === 'prod' ? '30 1 * * * ' : '30 * * * * * ';
  let error = false;
  const tasks = cron.getTasks();
  let deferReadOrdersTaskExists = false;

  for (let [key, value] of tasks.entries()) {
    if(value.options.name === 'deferReadOrders-task'){
      deferReadOrdersTaskExists = true;
      break;
    }
  }

  if(!deferReadOrdersTaskExists){
    job = cron.schedule(cronTime, async function execute() {
      const date = new Date().toLocaleString();
      const days = [14,7,3];
      const res = deferReadOrders(days, shop, process);
    },{
      name: 'deferReadOrders-task',
      scheduled: false
    });
  }

  try{
    action == 'stop' ? job.stop() : job.start();
  }
  catch(error){
    job.stop();
    error = true;
  }

  return json({
    cronTime,
    action,
    error
  });
}

export default function Cron() {

  const errors = useActionData()?.errors || {};
  const responseData = useActionData()?.responseData || {};
  const submit = useSubmit();
  const cronForm = useLoaderData();

  const [formState, setFormState] = useState(cronForm);
  const [cleanFormState, setCleanFormState] = useState(cronForm);
  const [viewIcon, setViewIcon] = useState(true);
  const [textFieldType, setTextFieldType] = useState('password');
  const navigate = useNavigate();

  useEffect(() => {
    viewIcon ? setTextFieldType('password') : setTextFieldType('text');
  }, [viewIcon]);

  function changePasswordVisibility() {
    setViewIcon(!viewIcon);
  }

  function handleSubmit(action) {
    const data$ = {
      ownerKey: formState.ownerKey || "",
      action
    };
    setCleanFormState({ ...formState });
    submit(data$, { method: "post" });
  }

  return (
    <Page>
      ...add owner key password field and cron stop/start buttons
    </Page>
  );
}
rm-philmells
Shopify Partner
6 0 3

Yeah, that's something I was looking at too originally (as well as a number of other timing libraries) but in our case it was a bit problematic so it wouldn't work reliably for us.

Charles_Roberts
Shopify Partner
44 0 8

I guess the only issue with node-cron is that whenever I deploy to fly.io, the cron job, needs to be restarted again.

 

I did look into using supercronic and even managed to install it with a Dockerfile, like:

 

FROM node:18-alpine

EXPOSE 3000
WORKDIR /app
COPY . .

RUN npm install
RUN npm run build

# You'll probably want to remove this in production, it's here to make it easier to test things!
RUN rm prisma/dev.sqlite
RUN npx prisma migrate dev --name init

# Install prerequisites
RUN @pk add --update curl

# Latest releases available at https://github.com/aptible/supercronic/releases
ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.29/supercronic-linux-amd64 \
SUPERCRONIC=supercronic-linux-amd64 \
SUPERCRONIC_SHA1SUM=cd48d45c4b10f3f0bfdd3a57d054cd05ac96812b

RUN curl -fsSLO "$SUPERCRONIC_URL" \
&& echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \
&& chmod +x "$SUPERCRONIC" \
&& mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \
&& ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic

CMD ["npm", "run", "start"]

 

But then I couldn't work out how to update the fly.toml file to use two different processes?

The example uses a rails server, not NodeJs, so it was a little hard to work out what I needed to include and what to exclude?

 

fly.toml

 

# fly.toml app configuration file generated for sb-user-admin-app-v1 on 2024-03-22T11:44:39Z
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#

app = 'sb-user-admin-app-v1'
primary_region = 'ams'

[build]

[processes]
# web = "bin/rails fly:server" # REMOVE????? // I am not using Rails; I am using NodeJs/remix
cron = "supercronic /app/crontab"
# https://fly.io/docs/apps/processes/ It says:
# Once there’s a [processes] section in your config, flyctl assumes this is a complete list of your processes. If you want an app process group alongside others, add it to the config explicitly.
# So maybe, we should add:
app = 'sb-user-admin-app-v1' # But is this the correct value?

[env]
PORT = "8081"
SHOPIFY_APP_URL = "https://sb-user-admin-app-v1.fly.dev"
SHOPIFY_API_KEY = "[SHOPIFY_API_KEY ]"
SCOPES = "read_checkouts,read_content,read_customers,read_files,read_orders,read_products,write_checkouts,write_customers,write_files,write_orders,write_products,write_themes,write_content"
DATABASE_URL = "file:/data/dev.sqlite"
DEFER_TOKEN = "[DEFER_TOKEN]"
TZ="Europe/London"

[experimental]
cmd = "start_with_migrations.sh"
entrypoint = "sh"

[http_service]
internal_port = 8081
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ['app'] # KEEP????? // My remix app has been installed at /app

[mounts]
source = "sb_user_admin_app_v1_data"
destination = "/data"

[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1