Search code examples
javascriptruby-on-railsweb-workerwebpackerstimulusjs

What is the Rails 6 Way to load a web worker?


I am using a standard Rails 6 setup with webpacker. I am using Stimulus for JavaScript, but that isn't too important.

For my app, I have a timer that needs to keep running even when the browser tab is not active. Since it seems like setInterval can stop when a tab is not active, I have dived into writing a web worker. I have read this question, but its solutions don't seem to be appropriate for modern-day Rails.

I wish I could just handle this all through my Stimulus controller, but my understanding is that the web worker file needs to be separate, so I'm trying to figure out how to properly load it.

I have created a file for the worker, timer.js. Where should I put this file, and how can I reference its URL in order to start it as a worker?

  • I need to be able to render its URL in one of my view ERB files so I can pass its URL to the Stimulus controller, and then start the worker with new Worker(theUrl). However, <%= javascript_path(...) %> seems to be for asset pipeline only.
  • I want to use import Rails from "@rails/ujs" in my worker file so I can use it to easily make AJAX POST requests, so I assume the worker file will need to be tied in with webpack somehow.

Currently I've just put it at public/timer.js, but I get an error in my browser console on load, so I assume I'm doing it wrong:

SyntaxError: import declarations may only appear at top level of a module

What's the right way to load a web worker in Rails 6?

timer.js

Here's the contents of the worker file, in case it matters. (I know it's crude; this is just a draft.)

import Rails from "@rails/ujs";

let timerInterval = null;
let timeRemaining = null;
let postFailures = 0;
let postUrl = null;

function finishActivity() {
  Rails.ajax({
    type: "POST",
    url: postUrl,
    success: () => {
      postFailures = 0;
    },
    error: () => {
      postFailures++;
      if (postFailures < 5) {
        setTimeout(finishActivity, 1000);
      } else {
        alert("Error.");
      }
    },
  });
}

self.addEventListener("message", (event) => {
  if (event.data.timeRemaining) {
    timeRemaining = event.data.timeRemaining;
    if (timerInterval) clearInterval(timerInterval);
    timerInterval = setInterval(() => {
      if (timeRemaining > 0) {
        timeRemaining = timeRemaining - 0.01;
        postMessage({ timeRemaining: timeRemaining });
      } else {
        timerInterval = null;
        clearInterval(timerInterval);
        finishActivity();
      }
    }, 10);
  }
  if (event.data.postUrl) {
    postUrl = event.data.postUrl;
  }
}, false);


Solution

  • It's been a while since I found the solution to this, but I'm coming back now to share what I found. This is the solution I went with in the pet project I was working on some months ago. I don't promise that it's the best (or even proper) way to do things, but it worked for me.

    I created the worker file at public/timer_worker.js:

    let timerInterval = null;
    let timerTimeout = 10;
    
    function setTimerInterval() {
      if (!timerInterval) {
        timerInterval = setInterval(function() {
          postMessage("timerTick");
        }, timerTimeout);
      }
    }
    
    function clearTimerInterval() {
      clearInterval(timerInterval);
      timerInterval = null;
    }
    
    onmessage = function(event) {
      if ("run_flag" in event.data) {
        if (event.data["run_flag"]) {
          setTimerInterval();
        } else {
          clearTimerInterval();
        }
      } else if ("set_timeout" in event.data) {
        timerTimeout = event.data["set_timeout"];
      }
    }
    
    

    In my webpack entry point at app/javascript/packs/application.js, I initialized the worker on the global window object, so I could reference it elsewhere:

    window.App = {};
    App.timerWorker = new Worker("/timer_worker.js");
    

    That's one way to hook up the worker. After this, I was able to utilize it in my Stimulus controller. Though the worker could be utilized in a similar manner without Stimulus.

    Here's the Stimulus controller I ended up using. Of course, some of this content is specific to my use case. But the key parts are the message handling, which demonstrates how it interfaces with the worker. I haven't abbreviated this controller in any way for this post, in hopes that it will paint a better picture of the functionality.

    import { Controller } from "stimulus";
    import Rails from "@rails/ujs";
    
    export default class extends Controller {
      static targets = [ "timer", "progressBar" ];
    
      static values = {
        timeRemaining: Number,
        activityDuration: Number,
        postUrl: String,
      }
    
      connect() {
        App.timerWorker.postMessage({ "run_flag": true });
        this.timerTarget.textContent = Math.ceil(this.timeRemainingValue);
        this.postFailures = 0;
    
        let controller = this;
        App.timerWorker.onmessage = function() {
          if (controller.timeRemainingValue > 0) {
            controller.timeRemainingValue = controller.timeRemainingValue - 0.01;
          }
    
          if (controller.timeRemainingValue > 0) {
            controller.timerTarget.textContent = Math.ceil(controller.timeRemainingValue).toString();
            controller.progressBarTarget.style.width = `${(1 - (controller.timeRemainingValue / controller.activityDurationValue)) * 100}%`;
          } else {
            App.timerWorker.postMessage({ "run_flag": false });
            controller.finishActivity();
          }
        }
      }
    
      finishActivity() {
        let controller = this;
        Rails.ajax({
          type: "POST",
          url: controller.postUrlValue,
          success: () => {
            this.postFailures = 0;
            App.timerWorker.postMessage({ "set_timeout": 10 });
          },
          error: (e, xhr) => {
            this.postFailures++;
            if (this.postFailures < 3) {
              App.timerWorker.postMessage({ "run_flag": true });
              App.timerWorker.postMessage({ "set_timeout": 1000 });
            } else if (this.postFailures < 5 || xhr === "") {
              App.timerWorker.postMessage({ "run_flag": true });
              App.timerWorker.postMessage({ "set_timeout": 60000 });
            }
          },
        });
      }
    }