rapier.js icon indicating copy to clipboard operation
rapier.js copied to clipboard

incompatible with native javascript modules

Open trusktr opened this issue 5 months ago • 2 comments

It'd be nice to update import statements so that the lib is easy to import with native ES Modules.

At the moment, this does not work:

<script type="importmap">
	{
		"imports": {
			"@dimforge/rapier3d": "https://cdn.jsdelivr.net/npm/@dimforge/[email protected]/rapier.js"
		}
	}
</script>

<script type="module">
	import * as RAPIER from '@dimforge/rapier3d'
</script>

This will fail with

GET https://cdn.jsdelivr.net/npm/@dimforge/[email protected]/exports net::ERR_ABORTED 404 (Not Found)

This is because https://cdn.jsdelivr.net/npm/@dimforge/[email protected]/exports does not exist. The file https://cdn.jsdelivr.net/npm/@dimforge/[email protected]/exports.js does exist, but appending .js to URLs is not part of native ES Module behavior. A URL to JavaScript can be anything, and only the MIME type determines if it will be executed as JavaScript.

Solving this problem has a potentially easy solution: adding .js to all import statements.

trusktr avatar Sep 21 '25 01:09 trusktr

Here's a starter PR:

  • https://github.com/dimforge/rapier.js/pull/349

I was not able to build the Rust Wasm stuff, and the link in the error output leads to the rustwasm deprecation notice. In theory, that's all that is needed.

The raw.ts file might still need the .js extension, but that file doesn't exist so I didn't verify it.

trusktr avatar Sep 21 '25 02:09 trusktr

In the meantime, there are a couple of workarounds:

  • copy over the JS files from NPM or JSDelivr, and add .js extensions to those
  • install a service worker that will append .js extensions before making the actual request

EDIT: Got it working with a service worker. Here's what the service worker looks like:

serviceworker.js
// Service Worker to automatically add .js extensions for @dimforge/rapier3d imports

self.addEventListener("fetch", (event) => {
	const url = new URL(event.request.url);


	// Check if this is a request that contains @dimforge/rapier3d
	if (url.href.includes("@dimforge/rapier3d")) {

		// Handle internal service worker fetches by removing the bypass header and passing through
		if (event.request.headers.get("X-SW-Bypass") === "true") {

			// Create a new request without the problematic header
			const newHeaders = new Headers(event.request.headers);
			newHeaders.delete("X-SW-Bypass");

			const newRequest = new Request(event.request.url, {
				method: event.request.method,
				headers: newHeaders,
				body: event.request.body,
				mode: event.request.mode,
				credentials: event.request.credentials,
				cache: event.request.cache,
				redirect: event.request.redirect,
				referrer: event.request.referrer,
				referrerPolicy: event.request.referrerPolicy,
				integrity: event.request.integrity,
			});

			// Pass the modified request through
			event.respondWith(fetch(newRequest));
			return;
		}

		// Handle .wasm files by returning a JavaScript wrapper
		if (url.pathname.endsWith(".wasm")) {

			event.respondWith(
				(async () => {
					try {
						// First, instantiate the WASM in the service worker to inspect its exports

						const wasmResponse = await fetch(url.href); // No bypass header needed in service worker
						const wasmBytes = await wasmResponse.arrayBuffer();

						// Check if this looks like WASM (starts with magic number)
						const firstBytes = new Uint8Array(wasmBytes.slice(0, 4));
						if (
							firstBytes[0] !== 0x00 ||
							firstBytes[1] !== 0x61 ||
							firstBytes[2] !== 0x73 ||
							firstBytes[3] !== 0x6d
						) {
							throw new Error("Fetched content is not a valid WASM binary");
						}

						// Instantiate with stub imports to get the exports
						const stubImports = {
							"./rapier_wasm3d_bg.js": new Proxy(
								{},
								{
									get() {
										return () => {}; // Return stub function for any import
									},
								}
							),
						};

						const inspectionResult = await WebAssembly.instantiate(
							wasmBytes,
							stubImports
						);
						const wasmExports = inspectionResult.instance.exports;
						const exportNames = Object.keys(wasmExports);


						// Now generate a JavaScript wrapper with all the required exports
						const jsWrapper = /*js*/ `
// Generated JavaScript wrapper for WASM module: ${url.pathname}
// Auto-discovered exports: ${exportNames.join(", ")}

// Load WASM using top-level await
const wasmUrl = '${url.href}';

// The WASM file expects imports, provide stub implementations
const imports = {
	"./rapier_wasm3d_bg.js": new Proxy({}, {
		get() {
			return () => {}; // Return stub function for any import
		}
	})
};

// Try WebAssembly.instantiateStreaming first
let wasmInstance;
try {
	const result = await WebAssembly.instantiateStreaming(
		fetch(wasmUrl, { headers: { 'X-SW-Bypass': 'true' } }),
		imports
	);
	wasmInstance = result.instance;
} catch (streamError) {

	const response = await fetch(wasmUrl, { headers: { 'X-SW-Bypass': 'true' } });
	const bytes = await response.arrayBuffer();

	const result = await WebAssembly.instantiate(bytes, imports);
	wasmInstance = result.instance;
}

const wasmExports = wasmInstance.exports;

// Export all discovered WASM exports as named exports (actual values, not promises)
${exportNames.map( ( name ) => `export const ${name} = wasmExports.${name};` ).join( '\n' )}

// Default export returns the exports object for star imports
export default wasmExports;
`;

						return new Response(jsWrapper, {
							status: 200,
							headers: {
								'Content-Type': 'application/javascript; charset=utf-8',
								'Access-Control-Allow-Origin': '*',
								'Cache-Control': 'no-cache',
							},
						} );

					} catch ( error ) {

						console.error(
							'Service Worker: Failed to generate WASM wrapper:',
							error
						);

						// Fallback: return a minimal wrapper
						const fallbackWrapper = `
console.error('Failed to load WASM: ${error.message}');
export default {};
`;

						return new Response(fallbackWrapper, {
							status: 500,
							headers: {
								'Content-Type': 'application/javascript; charset=utf-8',
								'Access-Control-Allow-Origin': '*',
								'Cache-Control': 'no-cache',
							},
						} );

					}

				} )()
			);

			return;

		}

		// Check if the URL doesn't already have an extension
		const pathname = url.pathname;
		const lastSegment = pathname.split("/").pop();

		// If the last segment doesn't have a file extension, try different strategies
		if (lastSegment && !lastSegment.includes(".") && !pathname.endsWith("/")) {

			// Strategy 1: Try adding .js extension first
			const jsUrl = new URL(url.href + ".js");

			// Strategy 2: Try treating as directory with index.js
			const indexUrl = new URL(url.href + "/index.js");

			// Intercept the request and try .js first, then /index.js as fallback
			event.respondWith(
				fetch(jsUrl.href, {
					method: event.request.method,
					headers: event.request.headers,
					body: event.request.body,
					mode: event.request.mode,
					credentials: event.request.credentials,
					cache: event.request.cache,
					redirect: event.request.redirect,
					referrer: event.request.referrer,
					referrerPolicy: event.request.referrerPolicy,
					integrity: event.request.integrity,
				})
					.then((response) => {
						// If the .js version is found (status 200), return it
						if (response.ok) {

							return response;

						}

						// If .js version failed, try /index.js

						return fetch(indexUrl.href, {
							method: event.request.method,
							headers: event.request.headers,
							body: event.request.body,
							mode: event.request.mode,
							credentials: event.request.credentials,
							cache: event.request.cache,
							redirect: event.request.redirect,
							referrer: event.request.referrer,
							referrerPolicy: event.request.referrerPolicy,
							integrity: event.request.integrity,
						} );

					} ).catch((error) => {

						console.error(
							"Fetch error for both .js and /index.js attempts:",
							error
						);
						throw error;

					} )
			);

			return;

		} else {

			console.log('Not redirecting - already has extension or ends with slash');

		}

	}

	// For all other requests, just pass through
	// (Don't call event.respondWith() to let the browser handle it normally)
});

self.addEventListener('install', () => {

	console.log('Rapier Import Service Worker installed');
	// Activate immediately
	self.skipWaiting();

});

self.addEventListener('activate', (event) => {

	console.log('Rapier Import Service Worker activated');
	// Take control of all clients immediately
	event.waitUntil(self.clients.claim());

});

It's is hacky, currently detects the wasm exports dynamically by instantiating it in the worker to detect the exports in order to dynamically create the named exports for import * as wasm from inside of rapier_wasm3d.js.

I could special-case rapier_wasm3d.js to replace import * as wasm from with a default import like import wasm from which would eliminate the need to know the exports up front.

trusktr avatar Sep 21 '25 02:09 trusktr