Service Worker FFI

For a project using WebCrypto where all text data has to be encrypted on the client side before being sent to the server, and decrypted on the way back, using ports would have been too much overhead.

Since tasks can be used to chain asynchronous work in Elm, I needed to find a way to access the WebCrypto API using a task.

Inspired by this post, I started looking into the feasibility of using service workers as an interface to JavaScript. Since the app already had a service worker due to being a PWA, it seemed like a good match.


On the Elm side I am sending a Http.task request with a particular method:

serviceWorkerRequest : String -> Json.Decode.Value -> Task Http.Error a
serviceWorkerRequest key body =
    Http.task
        { method = "CRYPTO"
        , headers = []
        , url = "https://" ++ key
        , body = Http.jsonBody body
        , resolver = resolver
        , timeout = Nothing
        }

If the service worker identifies this method it intercepts the request:

self.addEventListener("fetch", (e) =>
  e.request.method === "CRYPTO" ? e.respondWith(handlers(e.request)) : e
);

Iā€™m using the url to differentiate between ā€˜actionsā€™, then parsing the body and passing it to the relevant function:

const handlers = async (request) => {
  try {
    const action = new URL(request.url).host;

    switch (action) {
      case "decrypt": {
        const body = await request.json();

        return jsonResponse(decrypt(body));
      }
      case "encrypt": {
        const body = await request.json();

        return jsonResponse(encrypt(body));
      }
      default: {
        return errorResponse("unmatched-url");
      }
    }
  } catch (_) {
    return errorResponse("execution-failure");
  }
};

The Response object can be used to return a result:

const jsonResponse = (data) => new Response(JSON.stringify(data));

Or send back an error if something goes wrong:

const errorResponse = (code) =>
  new Response(JSON.stringify({ error: code }), { status: 400 });

Since the service worker is essential for certain features in the app, Elm needs to know if the service worker has been registered successfully:

const { Elm } = require("./Main.elm");

const startApp = (swActive) =>
  Elm.Main.init({
    node: document.getElementById("app"),
    flags: { swActive },
  });

const swEnabled = Boolean(
  window.navigator.serviceWorker &&
    typeof window.navigator.serviceWorker.register === "function"
);

if (swEnabled) {
  window.navigator.serviceWorker.register("/sw.js").then(
    () => startApp(true),
    () => /* registration failure */ startApp(false)
  );
} else {
  startApp(false);
}

Iā€™m also using clients.claim in the service worker activation handler to ensure all requests start passing through the ā€œfetchā€ handler immediately:

self.addEventListener("activate", (_event) =>
  self.clients.claim()
);

Overall I am happy with the results and would consider using this approach in future to interact with other JS APIs such as localStorage or IndexedDB.


Code:


Caveat:
Service workers are not available in Firefox private browsing or in non-Safari iOS browsers.

18 Likes

Hi! Looks really good. I have made something similar by monkey patching the XMLHttpRequest object. I also implemented handling of generators by using Partial content responses: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206 think that allows to have websockets or similar stuff.

Do you know if it is possible to achieve the same with the technique you implemented?

Iā€™m averse to monkey patching anything, but Iā€™d like to see what you mean by generator usage. Do you have any code you could post?

I will check if monkeypatching ellie I can have a working example.

I write to prevent the post from expiring.

I just want to point to this link: Rethinking Offline First sync for Service Workers | by Nolan Lawson | Offline Camp | Medium

JS-APIs using states may have problems in Service Workers.

This is soooooo clever!!

Elm really needs to embrace the notion of synchronous port Tasks that are - as in this example - pure computations. Others have tried to use web components to the same end

3 Likes

I think that what I did will be doable with service workers. I didnā€™t publish my package to the public repository because of its dependency in monkey patching. The trick for handling websockets is to return a status code 206 instead of 200. This keeps the client waiting for more chunks from the server.

I show you the places that take care of this functionality:

  1. If the function to call is a generator, we are gonna handle it as a chunked http response until the generator is consumed.
    NOTE: I decided to use status 206 instead of 200 in order to treat the callback in some kind of way that tells elm that more things are going to come just in case some update would kill this functionality, but I right now it donā€™t think it is needed.
    https://github.com/francescortiz/elm-effects-proxy/blob/de2d19fc887f2d91faed8604e5e20ddb0fe2c080/src/websocket-effects-patch.ts#L95,L110
  2. In elm response handling, we donā€™t need to do anything, because the elm/http package already considers status 206 to be a successful response.
    https://github.com/elm/http/blob/34a9a27411c2492d3e247ac75cd48e22b473bef5/src/Elm/Kernel/Http.js#L65
  3. We create some handler for asynchronous code, like a websockets wrapper.
    https://github.com/francescortiz/elm-effects-proxy/blob/de2d19fc887f2d91faed8604e5e20ddb0fe2c080/src/elm-effects-proxy-demo.html#L15-L23
  4. We trigger the asynchronous call from elm, either as command:
    https://github.com/francescortiz/elm-effects-proxy/blob/de2d19fc887f2d91faed8604e5e20ddb0fe2c080/src/EffectsProxyDemo.elm#L56-L59
    or as a chainable task:
    https://github.com/francescortiz/elm-effects-proxy/blob/de2d19fc887f2d91faed8604e5e20ddb0fe2c080/src/EffectsProxyDemo.elm#L64-L72

This is all. I think the same should be achievable with service workers. Do you know if this is the case?

1 Like

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.