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.