Skip to content

Commit e81525f

Browse files
committed
Use persistent storage for SW config to support browser-initiated SW terminations
1 parent 8fbfea7 commit e81525f

File tree

1 file changed

+88
-88
lines changed

1 file changed

+88
-88
lines changed

src/index.auth-sw.js

Lines changed: 88 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,30 @@
1+
/* eslint-disable no-undef */
12
/**
23
* Bytescale Auth Service Worker (SW)
34
*
4-
* What is this?
5-
* ------------
6-
* This script is designed to be imported into a 'service worker' that's included in a top-level script on the user's
7-
* web application's domain. This script intercepts FETCH requests to add JWTs (issued by the user's application) to
8-
* Bytescale CDN requests via the 'Authorization-Token' request header. This allows the Bytescale CDN to authorize
9-
* requests using 'Authorization-Token' headers as opposed to cookies, which are blocked by some browsers (including Safari).
5+
* This script should be referenced by the "serviceWorkerScript" field in the "AuthManager.beginAuthSession" method of
6+
* the Bytescale JavaScript SDK to append "Authorization" headers to HTTP requests sent to the Bytescale CDN. This
7+
* approach serves as an alternative to cookie-based authentication, which is incompatible with certain modern browsers.
108
*
11-
* Installation
12-
* ------------
13-
* 1. The user must add a root-level script to their application, under their web application's domain, that includes:
14-
* importScripts("https://js.bytescale.com/auth-sw/v1");
15-
* 2. This script MUST be hosted on the _exact domain_ your website is running on; you cannot host it from a different (sub)domain.
16-
* Explanation: service workers cannot be added cross-domain. This is a restriction of the service worker API.
17-
* 3. This script MUST be hosted in the root folder (e.g. '/bytescale-auth-sw.js' and not '/scripts/bytescale-auth-sw.js')
18-
* Explanation: service workers can only intercept HTTP requests from pages that are at the same level as, or lower than, the script's path.
19-
* 4. Add the 'serviceWorkerScript' field to the 'beginAuthSession' method call in your code, specifying the path to this script.
9+
* Documentation:
10+
* - https://www.bytescale.com/docs/types/BeginAuthSessionParams#serviceWorkerScript
2011
*/
21-
22-
// See: AuthSwConfigDto
23-
let config; // [{urlPrefix, headers, expires?}]
24-
25-
// This is a thunk created during the "install" event and called during the first "message" event.
26-
let resolveInitialConfig;
27-
28-
// Time to wait before activating to allow 'postMessage' to initialize our config -- should be near-instant as there's
29-
// no async calls between the 'register' and 'postMessage', so a short timeout is fine. If the timeout occurs, then the
30-
// new service worker will be used without any config, meaning some requests to private files will fail until
31-
// 'postMessage' is called by the client with the up-to-date config.
32-
const installTimeoutMs = 1000;
12+
let transientCache; // [{urlPrefix, headers, expires?}] (See: AuthSwConfigDto)
13+
const persistentCacheName = "bytescale-sw-config";
14+
const persistentCacheKey = "config";
3315

3416
console.log(`[bytescale] Auth SW Registered`);
3517

36-
/* eslint-disable no-undef */
3718
self.addEventListener("install", function (event) {
38-
event.waitUntil(install());
19+
// Typically service workers go: 'installing' -> 'waiting' -> 'activated'.
20+
// However, we skip the 'waiting' phase as we want this service worker to be used immediately after it's installed,
21+
// instead of requiring a page refresh if the browser already has an old version of the service worker installed.
22+
event.waitUntil(self.skipWaiting());
3923
});
4024

4125
self.addEventListener("activate", function (event) {
42-
// Immediately allow the service worker to intercept "fetch" events (instead of requiring a page refresh) if this is the first time this service worker is being installed.
26+
// Immediately allow the service worker to intercept "fetch" events (instead of requiring a page refresh) if this is
27+
// the first time this service worker is being installed.
4328
event.waitUntil(self.clients.claim());
4429
});
4530

@@ -48,23 +33,53 @@ self.addEventListener("message", event => {
4833
// See: AuthSwSetConfigDto
4934
if (event.data) {
5035
switch (event.data.type) {
36+
// Auth sessions are started/ended by calling SET_CONFIG with auth config or with 'undefined' config, respectively.
37+
// We use 'undefined' to end the auth session instead of unregistering the worker, as there may be multiple tabs
38+
// in the user's application, so while the user may sign out in one tab, they may remain signed in to another tab,
39+
// which may subsequently send a follow-up 'SET_CONFIG' which will resume auth.
5140
case "SET_BYTESCALE_AUTH_CONFIG":
52-
// Auth sessions are started/ended by calling SET_CONFIG with auth config or with 'undefined' config, respectively.
53-
// We use 'undefined' to end the auth session instead of unregistering the worker, as there may be multiple tabs
54-
// in the user's application, so while the user may sign out in one tab, they may remain signed in to another tab,
55-
// which may subsequently send a follow-up 'SET_CONFIG' which will resume auth.
56-
config = event.data.config;
57-
58-
if (resolveInitialConfig !== undefined) {
59-
resolveInitialConfig();
60-
resolveInitialConfig = undefined;
61-
}
41+
setConfig(event.data.config).then(
42+
() => {},
43+
e => console.error(`[bytescale] Auth SW failed to persist config.`, e)
44+
);
6245
break;
6346
}
6447
}
6548
});
6649

6750
self.addEventListener("fetch", function (event) {
51+
// Faster and intercepts only the required requests.
52+
// Called in almost all cases.
53+
const interceptSync = config => {
54+
const newRequest = interceptRequest(event, config);
55+
if (newRequest !== undefined) {
56+
event.respondWith(handleRequestErrors(newRequest));
57+
}
58+
};
59+
60+
// Slower and intercepts all requests (while still only rewriting the relevant requests).
61+
// Called only for the initial request after this Service Worker is restarted after going idle (e.g. after 30s on Firefox/Windows).
62+
const interceptAsync = async () =>
63+
await handleRequestErrors(interceptRequest(event, await getConfig()) ?? event.request);
64+
65+
// Makes it clearer to developers that the request failed for normal reasons (not reasons caused by this script).
66+
const handleRequestErrors = async request => {
67+
try {
68+
return await fetch(request);
69+
} catch (e) {
70+
throw new Error("Network request failed: see previous browser errors for the cause.");
71+
}
72+
};
73+
74+
// Optimization: avoids running async code (which necessitates intercepting all requests) when the config is already cached locally.
75+
if (transientCache !== undefined) {
76+
interceptSync(transientCache);
77+
} else {
78+
event.respondWith(interceptAsync());
79+
}
80+
});
81+
82+
function interceptRequest(event, config) {
6883
const url = event.request.url;
6984

7085
if (config !== undefined) {
@@ -74,68 +89,53 @@ self.addEventListener("fetch", function (event) {
7489
if (url.startsWith(urlPrefix) && event.request.method.toUpperCase() === "GET") {
7590
const newHeaders = new Headers(event.request.headers);
7691
for (const { key, value } of headers) {
77-
// Preserve existing headers in the request. This is crucial for 'fetch' requests that might already include
78-
// an "Authorization" header, enabling access to certain resources. For instance, the Bytescale Dashboard
79-
// uses an explicit "Authorization" header in a 'fetch' request to allow account admins to download private
80-
// files. In these scenarios, it's important not to replace these headers with the global JWT managed by the
81-
// AuthManager.
92+
// Preserve existing headers in the request. This is crucial for 'fetch' requests that might already
93+
// include an "Authorization" header, enabling access to certain resources. For instance, the Bytescale
94+
// Dashboard uses an explicit "Authorization" header in a 'fetch' request to allow account admins to
95+
// download private files. In these scenarios, it's important not to replace these headers with the global
96+
// JWT managed by the AuthManager.
8297
if (!newHeaders.has(key)) {
8398
newHeaders.set(key, value);
8499
}
85100
}
86-
const newRequest = new Request(event.request, {
101+
102+
return new Request(event.request, {
87103
mode: "cors", // Required for adding custom HTTP headers.
88104
headers: newHeaders
89105
});
90-
event.respondWith(fetch(newRequest));
91-
92-
// Do not match on any other configs
93-
return;
94106
}
95107
}
96108
}
97109
}
98-
});
99110

100-
async function withTimeout(promise, ms) {
101-
let timeoutHandle;
102-
const timeoutPromise = new Promise((resolve, reject) => {
103-
timeoutHandle = setTimeout(() => {
104-
reject(new Error(`Timed out after ${ms} milliseconds`));
105-
}, ms);
106-
});
107-
108-
return Promise.race([promise, timeoutPromise]).finally(() => {
109-
clearTimeout(timeoutHandle);
110-
});
111+
return undefined;
111112
}
112113

113-
async function install() {
114-
// Wait for the initial config to be received before activating this service worker.
115-
// This prevents us from replacing a functional service worker (with config) with a service worker that initially
116-
// has no config, and thus causes private file downloads to fail as they're temporarily not being authorized due to
117-
// the new service worker being active but not having its config yet.
118-
// ---
119-
// Timeout is required else all subsequent 'navigator.serviceWorker.register' calls for future service workers will
120-
// hang forever if the current service worker never completes its 'install' phase. Same applies to 'unregister' calls
121-
// for the current service worker.
122-
// ---
123-
try {
124-
await withTimeout(
125-
new Promise(resolve => {
126-
resolveInitialConfig = resolve;
127-
}),
128-
installTimeoutMs
129-
);
130-
} catch {
131-
// Not a big issue: it just means the service worker will be activated with blank config, so private files won't
132-
// be authorized until new config is received, which is undesirable if this service worker replaced an
133-
// already-functioning service worker that was correctly configured and was authorizing requests.
134-
console.warn("[bytescale] Auth SW initialization timeout.");
114+
async function getConfig() {
115+
if (transientCache !== undefined) {
116+
return transientCache;
135117
}
136118

137-
// Typically service workers go: 'installing' -> 'waiting' -> 'activated'.
138-
// However, we skip the 'waiting' phase as we want this service worker to be used immediately after it's installed,
139-
// instead of requiring a page refresh if the browser already has an old version of the service worker installed.
140-
await self.skipWaiting();
119+
const cache = await getCache();
120+
const configResponse = await cache.match(persistentCacheKey);
121+
if (configResponse !== undefined) {
122+
const config = await configResponse.json();
123+
transientCache = config;
124+
return config;
125+
}
126+
127+
return undefined;
128+
}
129+
130+
async function setConfig(config) {
131+
// Ensures "fetch" events can start seeing the config immediately. Persistent config is only required for when this
132+
// service worker expires (after 30s on some browsers, like FireFox on Windows).
133+
transientCache = config;
134+
135+
const cache = await getCache();
136+
await cache.put(persistentCacheKey, new Response(JSON.stringify(config)));
137+
}
138+
139+
function getCache() {
140+
return caches.open(persistentCacheName);
141141
}

0 commit comments

Comments
 (0)