How do I cache bust imported modules in es6?
Asked Answered
B

15

64

ES6 modules allows us to create a single point of entry like so:

// main.js

import foo from 'foo';

foo()
<script src="scripts/main.js" type="module"></script>

foo.js will be stored in the browser cache. This is desirable until I push a new version of foo.js to production.

It is common practice to add a query string param with a unique id to force the browser to fetch a new version of a js file (foo.js?cb=1234)

How can this be achieved using the es6 module pattern?

Biodegradable answered 6/12, 2017 at 13:30 Comment(9)
Awesome question! Finally I've found a word of reason. Just thank you for asking! My main cocern against all those fancy ES Modules was cache busting. It'll fail on race conditions every time the site will get updated. And I love CommonJS for dynamic imports: const a = require(b ? 'm1' : 'm2'). import a from (b ? 'm1' :'m2') woun't work.Wilinski
Did you manage to find a solution for this @spinners? I have the exact same problem!Outsoar
Have you figured out a solution to this? This issue is currently a significant problem for me and I am surprised that this currently seems to be the only question on the web about it. I have resorted to adding a "?v=x.x.x" to the end of every import statement in my Javascript files. So I have 'import foo from 'foo?v=1.x.x'; for all my modules. This is a really annoying solution because it means I modify hundreds of files in source control in every release, just because I have to modify the version number, even if no actual code in the file changed. Any info is greatly appreciated.Navy
^^ that's what I ended up doingBiodegradable
Is there still no better way in 2020? I read the answer suggesting Etag, which is elegant, but it's not always possible to configure the server.Illailladvised
What's the problem in using import foo from './foo.js?cb=1234';? You should write it this way for native js anyway.Belmonte
It seems to me that modules were never intended to be used on client side but only for making server side code in nodejs. I can't believe cache problems isn't solved more elegantly than what I read as replies here.Cymatium
In my humble opinion, a lot of these answers below are a little bit overkill. If you're in dire straits and need a fix for this, just add a querystring cache-buster to your script tag src. Adding a content-hash to filenames is nice, but it's not really necessary. The thought that querystring cache-busting isn't effective is a long-standing myth that has been disproven. You don't have to rename files to bust the cache anymore. It's much much easier to add a querystring.Greegree
2022 ... Still no clear solution. Ho comes Ecma hasn't taken care of this?Parament
P
18

There is one solution for all of this that doesn't involve query string. let's say your module files are in /modules/. Use relative module resolution ./ or ../ when importing modules and then rewrite your paths in server side to include version number. Use something like /modules/x.x.x/ then rewrite path to /modules/. Now you can just have global version number for modules by including your first module with <script type="module" src="/modules/1.1.2/foo.mjs"></script>

Or if you can't rewrite paths, then just put files into folder /modules/version/ during development and rename version folder to version number and update path in script tag when you publish.

Platina answered 29/11, 2018 at 21:17 Comment(1)
IMHO this is the best option. New version just goes in a new directory, this way both old and new will work and be supported. Then when you decide it's time delete old versions.Charterhouse
M
13

HTTP headers to the rescue. Serve your files with an ETag that is the checksum of the file. S3 does that by default at example. When you try to import the file again, the browser will request the file, this time attaching the ETag to a "if-none-match" header: the server will verify if the ETag matches the current file and send back either a 304 Not Modified, saving bandwith and time, or the new content of the file (with its new ETag).

This way if you change a single file in your project the user will not have to download the full content of every other module. It would be wise to add a short max-age header too, so that if the same module is requested twice in a short time there won't be additional requests.

If you add cache busting (e.g. appending ?x={randomNumber} through a bundler, or adding the checksum to every file name) you will force the user to download the full content of every necessary file at every new project version.

In both scenario you are going to do a request for each file anyway (the imported files on cascade will produce new requests, which at least may end in small 304 if you use etags). To avoid that you can use dynamic imports e.g if (userClickedOnSomethingAndINeedToLoadSomeMoreStuff) { import('./someModule').then('...') }

Melano answered 28/6, 2019 at 6:22 Comment(2)
Using ETag of course still leads to 1 HTTP request per module, so potentially a lot of overhead - adding max-age causes more problems, as modules expire individually, meaning someone might get incompatible module versions when you deploy updates. Cache busting with a content checksum in the filename only forces updates to changed modules, so that is the best strategy I know of - and probably the only strategy that lets you cache perpetually, with no HTTP overhead, and precise updates. Too bad we still have to use bundlers just to get proper caching. 😕Gregor
ETags are only re-checked periodically though, often according to Heuristic freshness checking: developer.mozilla.org/en-US/docs/Web/HTTP/…. If like me, you are serving static files from an NPM repo which sets Last-Modified dates on the files to 1985 (github.com/angular/components/issues/11009), the ETags would only be checked every 3 to 4 years!Arleanarlee
M
12

You can use an importmap for this purpose. I've tested it at least in Edge. It's just a twist on the old trick of appending a version number or hash to the querystring. import doesn't send the querystring onto the server but if you use an importmap it will.

<script type="importmap">
    {
      "imports": {
        "/js/mylib.js": "/js/mylib.js?v=1",
        "/js/myOtherLib.js": "/js/myOtherLib.js?v=1"
      }
    }    
</script>

Then in your calling code:

import myThing from '/js/mylib.js';
import * as lib from '/js/myOtherLib.js';
Meggs answered 19/1, 2023 at 19:34 Comment(0)
W
2

From my point of view dynamic imports could be a solution here.

Step 1) Create a manifest file with gulp or webpack. There you have an mapping like this:

export default {
    "/vendor/lib-a.mjs": "/vendor/lib-a-1234.mjs",
    "/vendor/lib-b.mjs": "/vendor/lib-b-1234.mjs"
};

Step 2) Create a file function to resolve your paths

import manifest from './manifest.js';

const busted (file) => {
 return manifest[file];
};

export default busted;

Step 3) Use dynamic import

import busted from '../busted.js';

import(busted('/vendor/lib-b.mjs'))
  .then((module) => {
    module.default();
});

I give it a short try in Chrome and it works. Handling relative paths is tricky part here.

Willettewilley answered 18/12, 2018 at 10:23 Comment(0)
U
2

A solution that crossed my mind but I wont use because I don't like it LOL is

window.version = `1.0.0`;

let { default: fu } = await import( `./bar.js?v=${ window.version }` );

Using the import "method" allows you to pass in a template literal string. I also added it to window so that it can be easily accessible no matter how deep I'm importing js files. The reason I don't like it though is I have to use "await" which means it has to be wrapped in an async method.

Unisexual answered 27/4, 2020 at 16:37 Comment(0)
P
2

I've created a Babel plugin which adds a content hash to each module name (static and dynamic imports).

import foo from './js/foo.js';

import('./bar.js').then(bar => bar());

becomes

import foo from './js/foo.abcd1234.js';

import('./bar.1234abcd.js').then(bar => bar());

You can then use Cache-control: immutable to let UAs (browsers, proxies, etc) cache these versioned URLs indefinitely. Some max-age is probably more reasonable, depending on your setup.

You can use the raw source files during development (and testing), and then transform and minify the files for production.

Plumbaginaceous answered 7/3, 2021 at 10:49 Comment(0)
S
2

what i did was handle the cache busting in webserver (nginx in my instance)

instead of serving

<script src="scripts/main.js" type="module"></script>

serve it like this where 123456 is your cache busting key

<script src="scripts/123456/main.js" type="module"></script>

and include a location in nginx like

location ~ (.+)\/(?:\d+)\/(.+)\.(js|css)$ {
  try_files $1/$2.min.$3 $uri;
}

requesting scripts/123456/main.js will serve scripts/main.min.js and an update to the key will result in a new file being served, this solution works well for cdns too.

Sophiesophism answered 29/11, 2021 at 22:40 Comment(0)
B
1

Just a thought at the moment but you should be able to get Webpack to put a content hash in all the split bundles and write that hash into your import statements for you. I believe it does the second by default.

Barranca answered 13/6, 2019 at 1:0 Comment(1)
Thanks for your answer. Do you have an example or documentation you can use to support your claim?Toul
O
1

This is an oldie, but none of these answers either made use of vanilla js or seemed a bit overly complex for what I needed. Here's a pretty simple way to cache-bust imports.

At a high level (or wherever makes sense), add your version number. You can make this a random number or a date or somesuch:

<script>
  var cacheBustingVersion = "1.0.1.2"
</script>

Next, when you do an import, at a lower level, do it like this:

const { myImport } = await import('../pathTo/import.js?v=' + cacheBustingVersion);
Outpouring answered 26/12, 2023 at 17:50 Comment(0)
H
0

You can use ETags, as pointed out by a previous answer, or alternatively use Last-Modified in relation with If-Modified-Since.

Here is a possible scenario:

  1. The browser first loads the resource. The server responds with Last-Modified: Sat, 28 Mar 2020 18:12:45 GMT and Cache-Control: max-age=60.
  2. If the second time the request is initiated earlier than 60 seconds after the first one, the browser serves the file from cache and doesn't make an actual request to the server.
  3. If a request is initiated after 60 seconds, the browser will consider cached file stale and send the request with If-Modified-Since: Sat, 28 Mar 2020 18:12:45 GMT header. The server will check this value and:
    • If the file was modified after said date, it will issue a 200 response with the new file in the body.
    • If the file was not modified after the date, the server will issue a304 "not modified" status with empty body.

I ended up with this set up for Apache server:

<IfModule headers_module>
  <FilesMatch "\.(js|mjs)$">
    Header set Cache-Control "public, must-revalidate, max-age=3600"
    Header unset ETag
  </FilesMatch>
</IfModule>

You can set max-age to your liking.

We have to unset ETag. Otherwise Apache keeps responding with 200 OK every time (it's a bug). Besides, you won't need it if you use caching based on modification date.

Haag answered 28/3, 2020 at 20:28 Comment(0)
S
0

If you are using Visual Studio 2022 and TypeScript to write your code, you can follow a convention of adding a version number to your script file names, e.g. MyScript.v1.ts. When you make changes and rename the file to MyScript.v2.ts Visual Studio shows the following dialog similar to the following:

Rename file dialog

If you click Yes it will go ahead and update all the files that were importing this module to refer to MyScript.v2.ts instead of MyScript.v1.ts. The browser will notice the name change too and download the new modules as expected.

It's not a perfect solution (e.g. if you rename a heavily used module, a lot of files can end up being updated) but it is a simple one!

Swayne answered 17/11, 2021 at 14:5 Comment(0)
S
0

this work for me

let url = '/module/foo.js'
url = URL.createObjectURL(await (await fetch(url)).blob())
let foo = await import(url)
Supercargo answered 28/3, 2022 at 8:42 Comment(0)
H
0

I came to the conclusion that cache-busting should not be used with ES Module.

Actually, if you have the versioning in the URL, the version is acting like a cache-busting. For instance https://unpkg.com/[email protected]/umd/react.production.min.js

If you don't have versioning in the URL, use the following HTTP header Cache-Control: max-age=0, no-cache to force the browser to always check if a new version of the file is available.

no-cache tells the browser to cache the file but to always perform a check

no-store tells the browser to don't cache the file. Don't use it!


Another approach: redirection

unpkg.com solved this problem with HTTP redirection. Therefore it is not an ideal solution because it involves 2 HTTP requests instead of 1.

  1. The first request is to get redirected to the latest version of the file (not cached, or cached for a short period of time)

  2. The second request is to get the JS file (cached)

=> All JS files include the versioning in the URL (and have an aggressive caching strategy)

For instance https://unpkg.com/[email protected]/umd/react.production.min.js

=> Removing the version in the URL, will lead to a HTTP 302 redirect pointing to the latest version of the file

For instance https://unpkg.com/react/umd/react.production.min.js

Make sure the redirection is not cached by the browser, or cached for a short period of time. (unpkg allows 600 seconds of caching, but it's up to you)

About multiple HTTP requests: Yes, if you import 100 modules, your browser will do 100 requests. But with HTTP2 / HTTP3, it is not a problem anymore because all requests will be multiplexed into 1 (it is transparent for you)

About recursion: If the module you are importing also imports other modules, you will want to check about <link rel="modulepreload"> (source Chrome dev blog).

The modulepreload spec actually allows for optionally loading not just the requested module, but all of its dependency tree as well. Browsers don't have to do this, but they can.

If you are using this technic in production, I am deeply interested to get your feedback!

Heliotherapy answered 7/7, 2022 at 15:45 Comment(0)
D
0

Append version to all ES6 imports with PHP

I didn't want to use a bundler only because of this, so I created a small function that modifies the import statements of all the JS files in the given directory so that the version is at the end of each file import path in the form of a query parameter. It will break the cache on version change.

This is far from an ideal solution, as all JS file contents are verified by the server on each request and on each version change the client reloads every JS file that has imports instead of just the changed ones.

But it is good enough for my project right now. I thought I'd share.

$assetsPath = '/public/assets'
$version = '0.7';

$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($assetsPath, FilesystemIterator::SKIP_DOTS)            );
foreach ($rii as $file) {
    if (pathinfo($file->getPathname())['extension'] === 'js') {
        $content = file_get_contents($file->getPathname());
        $originalContent = $content;
        // Matches lines that have 'import ' then any string then ' from ' and single or double quote opening then
        // any string (path) then '.js' and optionally numeric v GET param '?v=234' and '";' at the end with single or double quotes
        preg_match_all('/import (.*?) from ("|\')(.*?)\.js(\?v=\d*)?("|\');/', $content, $matches);
        // $matches array contains the following: 
        // Key [0] entire matching string including the search pattern
        // Key [1] string after the 'import ' word 
        // Key [2] single or double quotes of path opening after "from" word
        // Key [3] string after the opening quotes -> path without extension
        // Key [4] optional '?v=1' GET param and [5] closing quotes
        // Loop over import paths
        foreach ($matches[3] as $key => $importPath) {
            $oldFullImport = $matches[0][$key];
            // Remove query params if version is null
            if ($version === null) {
                $newImportPath = $importPath . '.js';
            } else {
                $newImportPath = $importPath . '.js?v=' . $version;
            }
            // Old import path potentially with GET param
            $existingImportPath = $importPath . '.js' . $matches[4][$key];
            // Search for old import path and replace with new one
            $newFullImport = str_replace($existingImportPath, $newImportPath, $oldFullImport);
            // Replace in file content
            $content = str_replace($oldFullImport, $newFullImport, $content);
        }
        // Replace file contents with modified one
        if ($originalContent !== $content) {
            file_put_contents($file->getPathname(), $content);
        }
    }
}

$version === null removes all query parameters of the imports in the given directory.

This adds between 10 and 20ms per request on my application (approx. 100 JS files when content is unchanged and 30—50ms when content changes).

Digastric answered 21/11, 2022 at 14:58 Comment(0)
L
-6

Use of relative path works for me:

import foo from './foo';

or

import foo from './../modules/foo';

instead of

import foo from '/js/modules/foo';

EDIT

Since this answer is down voted, I update it. The module is not always reloaded. The first time, you have to reload the module manually and then the browser (at least Chrome) will "understand" the file is modified and then reload the file every time it is updated.

Laural answered 13/3, 2020 at 18:13 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.