Making bundlers see non-JavaScript assets

Most web apps end up depending on more than just JavaScript modules. Web Workers, images, stylesheets, fonts, and WebAssembly modules are all common companions to component code. You could wire those up by hand in HTML, but when a stylesheet or icon belongs to a specific component, it's far more natural to reference it from that component's JavaScript and let the module system handle loading.

That's easy enough when code runs directly in the browser: just use a relative fetch() or construct a URL by hand. The problem appears once a build system enters the picture. Bundlers can't execute code to figure out what URLs will be requested at runtime, and they won't guess that a string literal is meant to be a file path. Something has to tell them.

Custom import schemes

One approach used by bundlers is to piggyback on the static import syntax. Some bundlers will recognize certain file extensions; others can be taught to resolve custom URL schemes through plugins:

// regular JavaScript import
import { loadImg } from './utils.js';

// special "URL imports" for assets
import imageUrl from 'asset-url:./image.png';
import wasmUrl from 'asset-url:./module.wasm';
import workerUrl from 'js-url:./worker.js';

loadImg(imageUrl);
WebAssembly.instantiateStreaming(fetch(wasmUrl));
new Worker(workerUrl);

When the bundler encounters such an import, it adds the referenced asset to the build graph, copies it to the output, runs any applicable optimizations, and returns the final URL. Reusing import syntax has advantages: URLs are static and relative to the current file, so a build tool can locate dependencies with certainty.

The major downside is that this code is no longer valid in a browser. A browser will happily attempt to resolve asset-url: as a real URL scheme and fail. That works if you rely on a bundler even during development, but it's increasingly common to run modules natively, particularly for quick demos.

A URL pattern that works everywhere

For reusable components, you want code that runs in both environments: directly in a browser and as part of a larger bundled app. Most modern bundlers support this pattern:

new URL('./relative-path', import.meta.url)

This is valid JavaScript with no special syntax. There is no custom URL scheme and no extension-based detection. It works in browsers immediately, yet bundlers can still recognize it statically and treat it almost like a dedicated import statement.

// regular JavaScript import
import { loadImg } from './utils.js';

loadImg(new URL('./image.png', import.meta.url));
WebAssembly.instantiateStreaming(
  fetch(new URL('./module.wasm', import.meta.url)),
  { /* … */ }
);
new Worker(new URL('./worker.js', import.meta.url));

The mechanism is straightforward. The URL constructor resolves its first argument—a relative path—against the absolute URL supplied as its second argument. With import.meta.url as the base, that path becomes relative to the current module file.

This is comparable to dynamic import(). You can call import(someUrl) with an arbitrary expression, but bundlers only guarantee special handling for the statically analyzable form import('./static-url.js'). Likewise, new URL(relativeUrl, customAbsoluteBase) is unremarkable, but a literal string combined with import.meta.url is a clear signal.

Why plain relative URLs fail

You might wonder why a bundler can't just detect fetch('./module.wasm') and treat it as a dependency. The problem is not really about detection—it's that the code is broken to begin with.

Dynamic APIs like fetch() resolve URLs against the document, not against the JavaScript file in which they appear. Take this file layout:

  • index.html:
    <script src="src/main.js" type="module"></script>
  • src/
    • main.js
    • module.wasm

If main.js calls fetch('./module.wasm'), the browser resolves that against the document URL. The request goes to http://example.com/module.wasm instead of http://example.com/src/module.wasm—either failing outright or loading an unintended resource.

Wrapping the URL in new URL('...', import.meta.url) fixes the resolution before the request is ever made, and gives the bundler a path it can trace at build time.

Tooling that already supports the pattern

Support for new URL(..., import.meta.url) is not theoretical. The following bundlers handle it today:

  • Webpack v5 (as URL assets)
  • Rollup, via plugins: @web/rollup-plugin-import-meta-assets for generic assets and @surma/rollup-plugin-off-main-thread for Workers
  • Parcel v2
  • Vite

WebAssembly toolchains

When you work with WebAssembly, you typically don't fetch the .wasm file manually. The toolchain emits JavaScript glue code that handles loading. Several toolchains now produce glue that uses the new URL pattern internally, which means the Wasm file is automatically discoverable by any bundler that understands the pattern.

C/C++ via Emscripten. Ask Emscripten to emit its glue as an ES6 module:

$ emcc input.cpp -o output.mjs
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6

With that flag, the output uses new URL(..., import.meta.url) under the hood. Adding -pthread extends this to WebAssembly threads: the generated Worker is included the same way and works in browsers and bundlers alike.

$ emcc input.cpp -o output.mjs -pthread
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6 -pthread

Rust via wasm-pack / wasm-bindgen. The default wasm-pack output relies on the WebAssembly ESM integration proposal, which remains experimental and currently only works when bundled with Webpack. The --target web flag produces browser-compatible ES6 module output:

$ wasm-pack build --target web

That output also uses the new URL pattern, so bundlers locate the Wasm file automatically.

Threads in Rust are more involved. The short version: you cannot use arbitrary thread APIs, but the wasm-bindgen-rayon adapter works with Rayon to spawn Workers on the web. Its JavaScript glue already includes the new URL(...) pattern, so the generated Workers are visible to bundlers.

What might replace this pattern

Two upcoming features could eventually offer dedicated syntax for importing non-JavaScript resources.

A dedicated import.meta.resolve(...) call would resolve module specifiers relative to the current module without needing a base URL parameter:

new URL('...', import.meta.url)
await import.meta.resolve('...')

Because it would go through the same module resolution system as import, it would integrate with import maps and custom resolvers, and it would be a stronger static signal than a URL constructor call. It exists as an experimental feature in Node.js, though some design questions remain for the web platform.

Import assertions allow importing non-ECMAScript module types, currently limited to JSON:

{ "answer": 42 }

import json from './foo.json' assert { type: 'json' };
console.log(json.answer); // 42

In principle, import assertions could cover the cases that new URL handles today. But types are added on a per-case basis; JSON is the only one now, with CSS modules on the horizon. Other asset kinds will continue to need a generic solution.

The most portable option today

Several mechanisms for loading non-JavaScript resources exist, but each has tradeoffs. Custom import schemes work in bundlers but break in native browser execution. Import assertions and import.meta.resolve promise dedicated syntax, but neither is ready to cover this use case broadly.

For now, new URL(..., import.meta.url) is the pattern with the widest reach: it is valid JavaScript in every browser, and it is recognized as a dependency declaration by major bundlers and WebAssembly toolchains.