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
3416console . log ( `[bytescale] Auth SW Registered` ) ;
3517
36- /* eslint-disable no-undef */
3718self . 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
4125self . 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
6750self . 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