Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[js/web] fix package export for bundlers #23257

Merged
merged 16 commits into from
Jan 9, 2025
Merged
8 changes: 0 additions & 8 deletions js/web/lib/backend-wasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Backend, env, InferenceSession, InferenceSessionHandler } from 'onnxrun

import { initializeOrtEp, initializeWebAssemblyAndOrtRuntime } from './wasm/proxy-wrapper';
import { OnnxruntimeWebAssemblySessionHandler } from './wasm/session-handler-inference';
import { scriptSrc } from './wasm/wasm-utils-import';

/**
* This function initializes all flags for WebAssembly.
Expand Down Expand Up @@ -54,13 +53,6 @@ export const initializeFlags = (): void => {
env.wasm.numThreads = Math.min(4, Math.ceil((numCpuLogicalCores || 1) / 2));
}
}

if (!BUILD_DEFS.DISABLE_DYNAMIC_IMPORT) {
// overwrite wasm paths override if not set
if (env.wasm.wasmPaths === undefined && scriptSrc && scriptSrc.indexOf('blob:') !== 0) {
env.wasm.wasmPaths = scriptSrc.substring(0, scriptSrc.lastIndexOf('/') + 1);
}
}
};

export class OnnxruntimeWebAssemblyBackend implements Backend {
Expand Down
43 changes: 35 additions & 8 deletions js/web/lib/build-def.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,19 @@ interface BuildDefinitions {
*/
readonly DISABLE_JSEP: boolean;
/**
* defines whether to disable the whole WebNN backend in the build.
* defines whether to disable the whole WebAssembly backend in the build.
*/
readonly DISABLE_WASM: boolean;
/**
* defines whether to disable proxy feature in WebAssembly backend in the build.
*/
readonly DISABLE_WASM_PROXY: boolean;
/**
* defines whether to disable training APIs in WebAssembly backend.
* defines whether to disable bundling the glue code generated by emscripten in the build.
*
* The value can only be set to `true` in ESM build.
*/
readonly DISABLE_TRAINING: boolean;
/**
* defines whether to disable dynamic importing WASM module in the build.
*/
readonly DISABLE_DYNAMIC_IMPORT: boolean;
readonly ENABLE_BUNDLE_WASM_JS: boolean;

// #endregion

Expand All @@ -48,9 +46,38 @@ interface BuildDefinitions {
/**
* placeholder for the import.meta.url in ESM. in CJS, this is undefined.
*/
readonly ESM_IMPORT_META_URL: string|undefined;
readonly ESM_IMPORT_META_URL: string | undefined;

// #endregion

/**
* placeholder for the bundle filename.
*
* This is used for bundler compatibility fix when using Webpack with `import.meta.url` inside ESM module.
*
* The default behavior of some bundlers (eg. Webpack) is to rewrite `import.meta.url` to the file local path at
* compile time. This behavior will break the following code:
* ```js
* new Worker(new URL(import.meta.url), { type: 'module' });
* ```
*
* This is because the `import.meta.url` will be rewritten to a local path, so the line above will be equivalent to:
* ```js
* new Worker(new URL('file:///path/to/your/file.js'), { type: 'module' });
* ```
*
* This will cause the browser fails to load the worker script.
*
* To fix this, we need to align with how the bundlers deal with `import.meta.url`:
* ```js
* new Worker(new URL('path-to-bundle.mjs', import.meta.url), { type: 'module' });
* ```
*
* This will make the browser load the worker script correctly.
*
* Since we have multiple bundle outputs, we need to define this placeholder in the build definitions.
*/
readonly BUNDLE_FILENAME: string;
}

declare const BUILD_DEFS: BuildDefinitions;
24 changes: 23 additions & 1 deletion js/web/lib/wasm/proxy-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from './proxy-messages';
import * as core from './wasm-core-impl';
import { initializeWebAssembly } from './wasm-factory';
import { importProxyWorker } from './wasm-utils-import';
import { importProxyWorker, inferWasmPathFromScriptSrc } from './wasm-utils-import';

const isProxy = (): boolean => !!env.wasm.proxy && typeof document !== 'undefined';
let proxyWorker: Worker | undefined;
Expand Down Expand Up @@ -98,6 +98,28 @@ export const initializeWebAssemblyAndOrtRuntime = async (): Promise<void> => {
proxyWorker.onmessage = onProxyWorkerMessage;
initWasmCallbacks = [resolve, reject];
const message: OrtWasmMessage = { type: 'init-wasm', in: env };
if (!BUILD_DEFS.ENABLE_BUNDLE_WASM_JS && !message.in!.wasm.wasmPaths && objectUrl) {
// if the proxy worker is loaded from a blob URL, we need to set the wasm path
const inferredWasmPath = inferWasmPathFromScriptSrc();
if (inferredWasmPath) {
message.in!.wasm.wasmPaths = inferredWasmPath;
}
}
if (
BUILD_DEFS.ENABLE_BUNDLE_WASM_JS &&
!message.in!.wasm.wasmPaths &&
BUILD_DEFS.ESM_IMPORT_META_URL?.startsWith('file:')
) {
// if `import.meta.url` is a file URL, it means it is overwriten by the bundler. in this case, unless the
// overrided wasm path is set, we need to use the bundler preferred URL format:
// new URL('filename', import.meta.url)
// so that the bundler can handle the file using corresponding loaders.
message.in!.wasm.wasmPaths = {
wasm: !BUILD_DEFS.DISABLE_JSEP
? new URL('ort-wasm-simd-threaded.jsep.wasm', BUILD_DEFS.ESM_IMPORT_META_URL).href
: new URL('ort-wasm-simd-threaded.wasm', BUILD_DEFS.ESM_IMPORT_META_URL).href,
};
}
proxyWorker.postMessage(message);
temporaryObjectUrl = objectUrl;
} catch (e) {
Expand Down
26 changes: 15 additions & 11 deletions js/web/lib/wasm/wasm-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { Env } from 'onnxruntime-common';

import type { OrtWasmModule } from './wasm-types';
import { importWasmModule } from './wasm-utils-import';
import { importWasmModule, inferWasmPathFromScriptSrc } from './wasm-utils-import';

let wasm: OrtWasmModule | undefined;
let initialized = false;
Expand Down Expand Up @@ -146,18 +146,22 @@ export const initializeWebAssembly = async (flags: Env.WebAssemblyFlags): Promis
};

if (wasmBinaryOverride) {
/**
* Set a custom buffer which contains the WebAssembly binary. This will skip the wasm file fetching.
*/
// Set a custom buffer which contains the WebAssembly binary. This will skip the wasm file fetching.
config.wasmBinary = wasmBinaryOverride;
} else if (wasmPathOverride || wasmPrefixOverride) {
/**
* A callback function to locate the WebAssembly file. The function should return the full path of the file.
*
* Since Emscripten 3.1.58, this function is only called for the .wasm file.
*/
config.locateFile = (fileName, scriptDirectory) =>
wasmPathOverride ?? (wasmPrefixOverride ?? scriptDirectory) + fileName;
// A callback function to locate the WebAssembly file. The function should return the full path of the file.
//
// Since Emscripten 3.1.58, this function is only called for the .wasm file.
config.locateFile = (fileName) => wasmPathOverride ?? wasmPrefixOverride + fileName;
} else if (mjsPathOverride && mjsPathOverride.indexOf('blob:') !== 0) {
// if mjs path is specified, use it as the base path for the .wasm file.
config.locateFile = (fileName) => new URL(fileName, mjsPathOverride).href;
} else if (objectUrl) {
const inferredWasmPath = inferWasmPathFromScriptSrc();
if (inferredWasmPath) {
// if the wasm module is preloaded, use the inferred wasm path as the base path for the .wasm file.
config.locateFile = (fileName) => inferredWasmPath + fileName;
}
}

ortWasmFactory(config).then(
Expand Down
69 changes: 50 additions & 19 deletions js/web/lib/wasm/wasm-utils-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,61 @@ import type { OrtWasmModule } from './wasm-types';
import { isNode } from './wasm-utils-env';

/**
* The classic script source URL. This is not always available in non ESModule environments.
* The origin of the current location.
*
* In Node.js, this is undefined.
*/
export const scriptSrc =
const origin = isNode || typeof location === 'undefined' ? undefined : location.origin;

const getScriptSrc = (): string | undefined => {
// if Nodejs, return undefined
isNode
? undefined
: // if It's ESM, use import.meta.url
(BUILD_DEFS.ESM_IMPORT_META_URL ??
// use `document.currentScript.src` if available
(typeof document !== 'undefined'
? (document.currentScript as HTMLScriptElement)?.src
: // use `self.location.href` if available
typeof self !== 'undefined'
? self.location?.href
: undefined));
if (isNode) {
return undefined;
}
// if It's ESM, use import.meta.url
if (BUILD_DEFS.IS_ESM) {
// For ESM, if the import.meta.url is a file URL, this usually means the bundler rewrites `import.meta.url` to
// the file path at compile time. In this case, this file path cannot be used to determine the runtime URL.
//
// We need to use the URL constructor like this:
// ```js
// new URL('actual-bundle-name.js', import.meta.url).href
// ```
// So that bundler can preprocess the URL correctly.
if (BUILD_DEFS.ESM_IMPORT_META_URL?.startsWith('file:')) {
// if the rewritten URL is a relative path, we need to use the origin to resolve the URL.
return new URL(new URL(BUILD_DEFS.BUNDLE_FILENAME, BUILD_DEFS.ESM_IMPORT_META_URL).href, origin).href;
}

return BUILD_DEFS.ESM_IMPORT_META_URL;
}

return typeof document !== 'undefined'
? (document.currentScript as HTMLScriptElement)?.src
: // use `self.location.href` if available
typeof self !== 'undefined'
? self.location?.href
: undefined;
};

/**
* The origin of the current location.
* The classic script source URL. This is not always available in non ESModule environments.
*
* In Node.js, this is undefined.
*/
const origin = isNode || typeof location === 'undefined' ? undefined : location.origin;
export const scriptSrc = getScriptSrc();

/**
* Infer the wasm path from the script source URL.
*
* @returns The inferred wasm path, or undefined if the script source URL is not available or is a blob URL.
*/
export const inferWasmPathFromScriptSrc = (): string | undefined => {
if (scriptSrc && !scriptSrc.startsWith('blob:')) {
return scriptSrc.substring(0, scriptSrc.lastIndexOf('/') + 1);
}
return undefined;
};

/**
* Check if the given filename with prefix is from the same origin.
Expand Down Expand Up @@ -132,7 +163,7 @@ export const importProxyWorker = async (): Promise<[undefined | string, Worker]>
* This is only available in ESM and when embedding is not disabled.
*/
const embeddedWasmModule: EmscriptenModuleFactory<OrtWasmModule> | undefined =
BUILD_DEFS.IS_ESM && BUILD_DEFS.DISABLE_DYNAMIC_IMPORT
BUILD_DEFS.IS_ESM && BUILD_DEFS.ENABLE_BUNDLE_WASM_JS
? // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
require(
!BUILD_DEFS.DISABLE_JSEP
Expand All @@ -145,7 +176,7 @@ const embeddedWasmModule: EmscriptenModuleFactory<OrtWasmModule> | undefined =
* Import the WebAssembly module.
*
* This function will perform the following steps:
* 1. If BUILD_DEFS.DISABLE_DYNAMIC_IMPORT is true, use the embedded module.
* 1. If the embedded module exists and no custom URL is specified, use the embedded module.
* 2. If a preload is needed, it will preload the module and return the object URL.
* 3. Otherwise, it will perform a dynamic import of the module.
*
Expand All @@ -158,8 +189,8 @@ export const importWasmModule = async (
prefixOverride: string | undefined,
isMultiThreaded: boolean,
): Promise<[undefined | string, EmscriptenModuleFactory<OrtWasmModule>]> => {
if (BUILD_DEFS.DISABLE_DYNAMIC_IMPORT) {
return [undefined, embeddedWasmModule!];
if (!urlOverride && !prefixOverride && embeddedWasmModule) {
return [undefined, embeddedWasmModule];
} else {
const wasmModuleFilename = !BUILD_DEFS.DISABLE_JSEP
? 'ort-wasm-simd-threaded.jsep.mjs'
Expand Down
47 changes: 39 additions & 8 deletions js/web/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const DEFAULT_DEFINE = {
'BUILD_DEFS.DISABLE_JSEP': 'false',
'BUILD_DEFS.DISABLE_WASM': 'false',
'BUILD_DEFS.DISABLE_WASM_PROXY': 'false',
'BUILD_DEFS.DISABLE_DYNAMIC_IMPORT': 'false',
'BUILD_DEFS.ENABLE_BUNDLE_WASM_JS': 'false',

'BUILD_DEFS.IS_ESM': 'false',
'BUILD_DEFS.ESM_IMPORT_META_URL': 'undefined',
Expand Down Expand Up @@ -115,7 +115,35 @@ async function minifyWasmModuleJsForBrowser(filepath: string): Promise<string> {
const TIME_TAG = `BUILD:terserMinify:${filepath}`;
console.time(TIME_TAG);

const contents = await fs.readFile(filepath, { encoding: 'utf-8' });
let contents = await fs.readFile(filepath, { encoding: 'utf-8' });

// Replace the following line to create worker:
// ```
// new Worker(new URL(import.meta.url), ...
// ```
// with:
// ```
// new Worker(import.meta.url.startsWith('file:')
// ? new URL(BUILD_DEFS.BUNDLE_FILENAME, import.meta.url)
// : new URL(import.meta.url), ...
// ```
//
// NOTE: this is a workaround for some bundlers that does not support runtime import.meta.url.
// TODO: in emscripten 3.1.61+, need to update this code.

// First, check if there is exactly one occurrence of "new Worker(new URL(import.meta.url)".
const matches = [...contents.matchAll(/new Worker\(new URL\(import\.meta\.url\),/g)];
if (matches.length !== 1) {
throw new Error(
`Unexpected number of matches for "new Worker(new URL(import.meta.url)" in "${filepath}": ${matches.length}.`,
);
}

// Replace the only occurrence.
contents = contents.replace(
/new Worker\(new URL\(import\.meta\.url\),/,
`new Worker(import.meta.url.startsWith('file:')?new URL(BUILD_DEFS.BUNDLE_FILENAME, import.meta.url):new URL(import.meta.url),`,
);

// Find the first and the only occurrence of minified function implementation of "_emscripten_thread_set_strongref":
// ```js
Expand Down Expand Up @@ -265,14 +293,17 @@ async function buildOrt({
const external = isNode
? ['onnxruntime-common']
: ['node:fs/promises', 'node:fs', 'node:os', 'module', 'worker_threads'];
const bundleFilename = `${outputName}${isProduction ? '.min' : ''}.${format === 'esm' ? 'mjs' : 'js'}`;
const plugins: esbuild.Plugin[] = [];
const defineOverride: Record<string, string> = {};
const defineOverride: Record<string, string> = {
'BUILD_DEFS.BUNDLE_FILENAME': JSON.stringify(bundleFilename),
};
if (!isNode) {
defineOverride.process = 'undefined';
defineOverride['globalThis.process'] = 'undefined';
}

if (define['BUILD_DEFS.DISABLE_DYNAMIC_IMPORT'] === 'true') {
if (define['BUILD_DEFS.ENABLE_BUNDLE_WASM_JS'] === 'true') {
plugins.push({
name: 'emscripten-mjs-handler',
setup(build: esbuild.PluginBuild) {
Expand All @@ -285,7 +316,7 @@ async function buildOrt({

await buildBundle({
entryPoints: ['web/lib/index.ts'],
outfile: `web/dist/${outputName}${isProduction ? '.min' : ''}.${format === 'esm' ? 'mjs' : 'js'}`,
outfile: `web/dist/${bundleFilename}`,
platform,
format,
globalName: 'ort',
Expand Down Expand Up @@ -585,7 +616,7 @@ async function main() {
isProduction: true,
outputName: 'ort.all.bundle',
format: 'esm',
define: { ...DEFAULT_DEFINE, 'BUILD_DEFS.DISABLE_DYNAMIC_IMPORT': 'true' },
define: { ...DEFAULT_DEFINE, 'BUILD_DEFS.ENABLE_BUNDLE_WASM_JS': 'true' },
});

// ort[.min].[m]js
Expand All @@ -598,7 +629,7 @@ async function main() {
isProduction: true,
outputName: 'ort.bundle',
format: 'esm',
define: { ...DEFAULT_DEFINE, 'BUILD_DEFS.DISABLE_WEBGL': 'true', 'BUILD_DEFS.DISABLE_DYNAMIC_IMPORT': 'true' },
define: { ...DEFAULT_DEFINE, 'BUILD_DEFS.DISABLE_WEBGL': 'true', 'BUILD_DEFS.ENABLE_BUNDLE_WASM_JS': 'true' },
});

// ort.webgpu[.min].[m]js
Expand All @@ -611,7 +642,7 @@ async function main() {
isProduction: true,
outputName: 'ort.webgpu.bundle',
format: 'esm',
define: { ...DEFAULT_DEFINE, 'BUILD_DEFS.DISABLE_WEBGL': 'true', 'BUILD_DEFS.DISABLE_DYNAMIC_IMPORT': 'true' },
define: { ...DEFAULT_DEFINE, 'BUILD_DEFS.DISABLE_WEBGL': 'true', 'BUILD_DEFS.ENABLE_BUNDLE_WASM_JS': 'true' },
});

// ort.wasm[.min].[m]js
Expand Down
4 changes: 4 additions & 0 deletions js/web/test/e2e/exports/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
This folder includes test data, scripts and source code that used to test the export functionality of onnxruntime-web package.

- [nextjs-default](./testcases/nextjs-default.md)
- [vite-default](./testcases/vite-default.md)
Loading
Loading