In one of my old GitHub Gists, I mentioned that JavaScript modules don’t generally work with the file:// protocol. The other day, someone responded with an ignorant comment. I reported it and GitHub marked it as a violation of their Acceptable Use Policies, so you can’t see it anymore. But this is what it said.

“this is so fucking stupid, JS really is something else. If I need to start a python server for a fucking GET request then I might as well do everything in python and not need to spin up a webserver.”

In the interest of learning, I have therefore decided to explain why JavaScript modules don’t generally work with the file:// protocol.

The same-origin policy

The same-origin policy is an essential security feature of web browsers. The idea is that a document or script loaded by one origin can only interact with resources from the same origin. An origin is the combination of a URL’s scheme (e.g. https://), hostname (e.g. barker.codes), and port (e.g. 443).

This helps isolate malicious documents and scripts. For example, imagine someone was signed into a website that contained their private data, such as a mail service provider. The same-origin policy would prevent a malicious website from using JavaScript to access that person’s data.

Cross-Origin Resource Sharing (CORS)

If a server wants to let other origins access its resources, it can use Cross-Origin Resource Sharing (CORS). A simple example would be a publicly accessible API such as JSONPlaceholder. It would likely set the Access-Control-Allow-Origin header to *, meaning all origins are allowed.

The file URI scheme

The file URI scheme defines the file:// protocol that web browsers use to access local files. Historically, local files from the same directory (and its subdirectories) were considered to be from the same origin. This was a security vulnerability. Modern browsers therefore treat all local files as having opaque origins that are considered distinct. This is why JavaScript modules don’t generally work with the file:// protocol.

“But wait,” I hear you say. “How come I can load regular (non-module) scripts using the file:// protocol?” That’s a great question! First, you need to understand that a request for a resource is made using one of the following request modes.

same-origin
Only allows requests to same-origin URLs.
cors
Allows cross-origin requests using the CORS protocol.
no-cors
Only allows CORS-safelisted methods and CORS-safelisted request headers.
navigate
A special mode used only when navigating between documents.
websocket
A special mode used only when establishing a WebSocket connection.

Unless the crossorigin attribute is present, the default request mode for embedded resources is no-cors. An embedded resource is one that initiates a request from the HTML, such as an <img>, <link>, or <script>.

The default request mode probably can’t be changed for fear of breaking the Web, but the standard says Even though the default request mode is no-cors, standards are highly discouraged from using it for new features. It is rather unsafe. Although they’ve been around for a while now, JavaScript modules are a “new feature” in this context. The browser’s built-in module loader uses CORS to fetch modules.

An exception

It’s inaccurate to say that JavaScript modules don’t work with the file:// protocol whatsoever. That’s why I keep saying they don’t generally work with it. An exception exists in the form of an inline module script that imports a module from an external server. As long as the external server allows the null origin (or all origins), it should work. This is because the browser’s built-in module loader doesn’t need to fetch the inline module script, and the opaque origin doesn’t matter because the external server allows it.

Here’s an example that imports Preact from a content delivery network (CDN). Try saving it as an HTML file and opening it in your browser via the file:// protocol. It should work. I have tested this in Firefox, Safari, Chrome, and Edge.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Preact Counter</title>
<script type="module">
import {
html,
render,
useState,
} from "https://esm.sh/htm@3.1.1/preact/standalone";

var root = document.getElementById("root");
render(html`<${Counter} />`, root);

function Counter({ start = 0 }) {
var [count, setCount] = useState(start);

function increment() {
setCount(count + 1);
}

return html`
<p>Count:
${count}</p>
<button type="button" onClick=
${increment}>
Increment
</button>
`
;
}
</script>
</head>

<body>
<div id="root"></div>
</body>
</html>

Summary

JavaScript modules generally don’t work with the file:// protocol because of security features that web browsers implement. They prevent malicious scripts from accessing files on your computer that they shouldn’t.