From e08ad12a2010ed3d4e4ce053b7a1ee8e1135ed26 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 14 Aug 2026 11:06:16 -0400 Subject: [PATCH 1/6] fix(ai): resolve the ONNX wasm directory against the app, not the route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getConfig` set `ort.env.wasm.wasmPaths = 'ort/'`. That prefix is document-relative, so the browser resolves it against the current route rather than against the application. It only finds the copy of `onnxruntime-web/dist` when the page sits exactly one segment deep, which is why it works for the examples and for `viewer.ohif.org/segmentation`. A viewer served from a deeper route — `/viewer/dicomweb`, say — requests `/viewer/ort/ort-wasm-*.wasm`, gets the SPA fallback's `index.html`, and compiling that as WebAssembly fails with `expected magic word 00 61 73 6d, found 3c 21 64 6f`. ONNX then reports "no available backend found", the SAM controller never finishes loading, and the failure surfaces to the user as a broken labelmap tool. Resolve the prefix against the base the bundler already uses for the assets it emits — webpack/rspack's public path, falling back to `document.baseURI` — which is the directory applications copy `onnxruntime-web/dist` into. The example runner copies it to `/ort` and is served with `publicPath: 'auto'`, so examples resolve to the same URL they do today. Also stop overwriting a location the application configured: apps serving the binaries from a CDN or a versioned path had their setting clobbered from every `ONNXSegmentationController` construction. Locating the binaries with `new URL(, import.meta.url)`, the way the codec and worker assets are located, is not available here: `onnxruntime-web@1.17` publishes only its JavaScript entry points through `exports`, so `onnxruntime-web/dist/ort-wasm-simd.jsep.wasm` does not resolve. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ai/src/ONNXSegmentationController.ts | 9 ++- packages/ai/src/utils/getOrtWasmPaths.ts | 71 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 packages/ai/src/utils/getOrtWasmPaths.ts diff --git a/packages/ai/src/ONNXSegmentationController.ts b/packages/ai/src/ONNXSegmentationController.ts index 6cad9e50d8..e5c0683bbe 100644 --- a/packages/ai/src/ONNXSegmentationController.ts +++ b/packages/ai/src/ONNXSegmentationController.ts @@ -14,6 +14,7 @@ import { LabelmapBaseTool, } from '@cornerstonejs/tools'; import { Events as aiEvents } from './enums'; +import getOrtWasmPaths from './utils/getOrtWasmPaths'; const { strategies } = cstSegmentation; const { fillInsideCircle } = strategies; @@ -1641,7 +1642,13 @@ export default class ONNXSegmentationController { } config.threads = parseInt(String(config.threads)); config.local = parseInt(config.local); - ort.env.wasm.wasmPaths = 'ort/'; + // Leave a location the application configured alone — it may be serving + // the binaries from a CDN or a versioned path. Otherwise resolve the + // copy of `onnxruntime-web/dist` against the application rather than + // against the current route. See `getOrtWasmPaths`. + if (!ort.env.wasm.wasmPaths) { + ort.env.wasm.wasmPaths = getOrtWasmPaths(); + } ort.env.wasm.numThreads = config.threads; ort.env.wasm.proxy = config.provider == 'wasm'; diff --git a/packages/ai/src/utils/getOrtWasmPaths.ts b/packages/ai/src/utils/getOrtWasmPaths.ts new file mode 100644 index 0000000000..2ec59d45c7 --- /dev/null +++ b/packages/ai/src/utils/getOrtWasmPaths.ts @@ -0,0 +1,71 @@ +/** + * ONNX Runtime fetches its WebAssembly binaries from the prefix held in + * `ort.env.wasm.wasmPaths` and feeds whatever comes back straight to + * `WebAssembly.instantiateStreaming`. + * + * Every other wasm binary in this repository is located with + * `new URL(, import.meta.url)` — the bundler resolves the file, + * emits it, and hands back an absolute URL that does not depend on the page + * the user is currently on. `onnxruntime-web@1.17` cannot be addressed that + * way: its `exports` map publishes only the JavaScript entry points, so + * `new URL('onnxruntime-web/dist/ort-wasm-simd.jsep.wasm', import.meta.url)` + * fails to resolve. Applications copy `onnxruntime-web/dist` next to their + * bundle instead — the example runner copies it to `/ort` + * (`utils/ExampleRunner/template-config.js`) — and point the runtime there. + * + * Pointing at it with a bare `'ort/'` is the part that breaks. A + * document-relative prefix resolves against the current *route*, not against + * the application, so it only finds the copy when the page sits exactly one + * segment deep — which is why it works for the examples and for + * `viewer.ohif.org/segmentation`. A viewer served from `/viewer/dicomweb` + * requests `/viewer/ort/ort-wasm-*.wasm`, receives the SPA fallback's + * `index.html`, and ONNX dies with `expected magic word 00 61 73 6d, found + * 3c 21 64 6f` followed by "no available backend found". + * + * So resolve the prefix against the base the bundler already uses for the + * assets it emits, which is the directory the copy lives in. + */ + +/** Directory applications copy `onnxruntime-web/dist` into. */ +export const DEFAULT_ORT_WASM_DIRECTORY = 'ort/'; + +/** + * webpack and rspack replace this identifier with the bundle's runtime public + * path (`output.publicPath` / `assetPrefix`, or the script's own directory + * when that is `'auto'`). It is declared rather than imported because other + * bundlers leave it undefined — the `typeof` guard below covers them. + */ +declare const __webpack_public_path__: string | undefined; + +function getBundlePublicPath(): string | undefined { + return typeof __webpack_public_path__ === 'string' && __webpack_public_path__ + ? __webpack_public_path__ + : undefined; +} + +/** + * Absolute URL prefix for the ONNX Runtime wasm binaries. + * + * @param directory - directory holding `onnxruntime-web/dist`, relative to the + * application. Defaults to `ort/`. + * @returns the prefix as an absolute URL, or `directory` unchanged when there + * is nothing to resolve it against (a non-browser context). + */ +export default function getOrtWasmPaths( + directory = DEFAULT_ORT_WASM_DIRECTORY +): string { + const base = getBundlePublicPath() ?? globalThis.document?.baseURI; + const documentHref = globalThis.location?.href; + + if (!base || !documentHref) { + return directory; + } + + try { + // The public path is often origin-relative (`/pacs/`), so anchor it to the + // document before the directory is resolved against it. + return new URL(directory, new URL(base, documentHref)).href; + } catch { + return directory; + } +} From 4f8f44ebf3d077ec26e9b48bd9d2f680f1e43189 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 14 Aug 2026 11:26:12 -0400 Subject: [PATCH 2/6] fix(ai): anchor the wasm prefix to the same base as the emitted assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve a relative public path against `document.baseURI` (falling back to `location.href`, for workers) rather than against `location.href` alone. That is the definition webpack and rspack generate for `__webpack_require__.b`, which is the base `new URL(, import.meta.url)` compiles down to — so the ONNX binaries now resolve against exactly the same base as the codec wasm. Only observable with a relative public path and a `` tag; identical everywhere else. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ai/src/utils/getOrtWasmPaths.ts | 25 ++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/utils/getOrtWasmPaths.ts b/packages/ai/src/utils/getOrtWasmPaths.ts index 2ec59d45c7..68fc5df56f 100644 --- a/packages/ai/src/utils/getOrtWasmPaths.ts +++ b/packages/ai/src/utils/getOrtWasmPaths.ts @@ -43,6 +43,19 @@ function getBundlePublicPath(): string | undefined { : undefined; } +/** + * The base a bundler anchors its emitted asset URLs to. This is the definition + * webpack and rspack generate for `__webpack_require__.b`, which is what + * `new URL(, import.meta.url)` compiles down to — so the runtime + * binaries end up resolved against the same base as the codec wasm. + */ +function getDocumentBase(): string | undefined { + return ( + (typeof document !== 'undefined' && document.baseURI) || + globalThis.location?.href + ); +} + /** * Absolute URL prefix for the ONNX Runtime wasm binaries. * @@ -54,17 +67,17 @@ function getBundlePublicPath(): string | undefined { export default function getOrtWasmPaths( directory = DEFAULT_ORT_WASM_DIRECTORY ): string { - const base = getBundlePublicPath() ?? globalThis.document?.baseURI; - const documentHref = globalThis.location?.href; + const documentBase = getDocumentBase(); + const base = getBundlePublicPath() ?? documentBase; - if (!base || !documentHref) { + if (!base || !documentBase) { return directory; } try { - // The public path is often origin-relative (`/pacs/`), so anchor it to the - // document before the directory is resolved against it. - return new URL(directory, new URL(base, documentHref)).href; + // The public path is rarely a full URL (`/pacs/`, or `auto` in a worker), + // so anchor it before the directory is resolved against it. + return new URL(directory, new URL(base, documentBase)).href; } catch { return directory; } From 57c51633bacaab63ac03cfbc1b6f5eeb1966599d Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Mon, 17 Aug 2026 13:16:09 -0400 Subject: [PATCH 3/6] fix(ai): resolve the ONNX wasm prefix against PUBLIC_URL, not the route The fallback branch still anchored `ort/` to the current document URL when no bundler public path was available, so a viewer served from a deep route asked for `/ort/` and got the SPA fallback's index.html. Take the application's public base instead: the bundler's asset base when it exposes one, then the injected `PUBLIC_URL`, then `document.baseURI` when the page carries an explicit ``, and finally `/`. Defaulting to `/` keeps the load path identical to the pre-fix behaviour for an application served from the root, keeps `ort/` in the same place relative to the app for a sub-path build, and leaves an explicit wasm directory untouched. The package had no jest project, so add one (with the babel config every other tested package carries) alongside the tests, including the deep-route-without- a-base-element case. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ai/babel.config.js | 1 + packages/ai/jest.config.js | 18 +++ packages/ai/src/utils/getOrtWasmPaths.test.ts | 115 ++++++++++++++++++ packages/ai/src/utils/getOrtWasmPaths.ts | 112 ++++++++++++++--- 4 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 packages/ai/babel.config.js create mode 100644 packages/ai/jest.config.js create mode 100644 packages/ai/src/utils/getOrtWasmPaths.test.ts diff --git a/packages/ai/babel.config.js b/packages/ai/babel.config.js new file mode 100644 index 0000000000..325ca2a8ee --- /dev/null +++ b/packages/ai/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../../babel.config.js'); diff --git a/packages/ai/jest.config.js b/packages/ai/jest.config.js new file mode 100644 index 0000000000..c853281819 --- /dev/null +++ b/packages/ai/jest.config.js @@ -0,0 +1,18 @@ +/* eslint-disable */ +const base = require('../../jest.config.base.js'); +const path = require('path'); + +module.exports = { + ...base, + displayName: 'ai', + testMatch: [ + ...base.testMatch, + '/src/**/*.test.ts', + '/src/**/*.spec.ts', + ], + moduleNameMapper: { + ...base.moduleNameMapper, + '^@cornerstonejs/(\\w+)/(.+)$': path.resolve(__dirname, '../$1/src/$2'), + '^@cornerstonejs/(.*)$': path.resolve(__dirname, '../$1/src'), + }, +}; diff --git a/packages/ai/src/utils/getOrtWasmPaths.test.ts b/packages/ai/src/utils/getOrtWasmPaths.test.ts new file mode 100644 index 0000000000..c9a29c8a25 --- /dev/null +++ b/packages/ai/src/utils/getOrtWasmPaths.test.ts @@ -0,0 +1,115 @@ +import getOrtWasmPaths from './getOrtWasmPaths'; + +type PublicUrlGlobal = typeof globalThis & { + PUBLIC_URL?: string; + __webpack_public_path__?: string; +}; + +const publicUrlGlobal = globalThis as PublicUrlGlobal; + +/** Puts the document on `route` the way a client-side router would. */ +function navigateTo(route: string) { + window.history.replaceState(null, '', route); +} + +function addBaseElement(href: string) { + const base = document.createElement('base'); + base.setAttribute('href', href); + document.head.appendChild(base); +} + +describe('getOrtWasmPaths', () => { + const publicUrl = process.env.PUBLIC_URL; + + beforeEach(() => { + delete publicUrlGlobal.PUBLIC_URL; + delete publicUrlGlobal.__webpack_public_path__; + delete process.env.PUBLIC_URL; + document.head.querySelectorAll('base').forEach((base) => base.remove()); + navigateTo('/'); + }); + + afterAll(() => { + if (publicUrl === undefined) { + delete process.env.PUBLIC_URL; + } else { + process.env.PUBLIC_URL = publicUrl; + } + }); + + it('resolves against the server root when nothing declares a base', () => { + expect(getOrtWasmPaths()).toBe('http://localhost/ort/'); + }); + + it('resolves against the application root from a deep route', () => { + // The bug this guards: `'ort/'` used to resolve against the route, so a + // viewer on /viewer/dicomweb fetched /viewer/ort/ and got index.html back. + navigateTo('/viewer/dicomweb/studies/1.2.3'); + + expect(getOrtWasmPaths()).toBe('http://localhost/ort/'); + }); + + it('keeps the same relative location for a sub-path build', () => { + process.env.PUBLIC_URL = '/pacs/'; + navigateTo('/pacs/viewer/dicomweb'); + + expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); + }); + + it('accepts a runtime PUBLIC_URL on the global', () => { + publicUrlGlobal.PUBLIC_URL = '/pacs/'; + navigateTo('/pacs/viewer/dicomweb'); + + expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); + }); + + it('tolerates a PUBLIC_URL without its trailing slash', () => { + process.env.PUBLIC_URL = '/pacs'; + navigateTo('/pacs/viewer/dicomweb'); + + expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); + }); + + it('prefers the bundler public path over PUBLIC_URL', () => { + // The examples and the docs site copy onnxruntime-web/dist beside the + // emitted bundle, which is what the bundler public path points at. + publicUrlGlobal.__webpack_public_path__ = 'http://cdn.example.com/app/'; + process.env.PUBLIC_URL = '/pacs/'; + + expect(getOrtWasmPaths()).toBe('http://cdn.example.com/app/ort/'); + }); + + it('ignores an empty bundler public path', () => { + publicUrlGlobal.__webpack_public_path__ = ''; + process.env.PUBLIC_URL = '/pacs/'; + + expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); + }); + + it('honours an explicit base element from a deep route', () => { + addBaseElement('http://localhost/viewer/'); + navigateTo('/viewer/dicomweb/studies/1.2.3'); + + expect(getOrtWasmPaths()).toBe('http://localhost/viewer/ort/'); + }); + + it('uses an application-supplied directory as given', () => { + navigateTo('/viewer/dicomweb'); + + expect(getOrtWasmPaths('https://cdn.example.com/onnx/1.17/')).toBe( + 'https://cdn.example.com/onnx/1.17/' + ); + expect(getOrtWasmPaths('/ort-1.17.1/')).toBe( + 'http://localhost/ort-1.17.1/' + ); + }); + + it('resolves a relative directory against the application base', () => { + process.env.PUBLIC_URL = '/pacs/'; + navigateTo('/pacs/viewer/dicomweb'); + + expect(getOrtWasmPaths('assets/ort/')).toBe( + 'http://localhost/pacs/assets/ort/' + ); + }); +}); diff --git a/packages/ai/src/utils/getOrtWasmPaths.ts b/packages/ai/src/utils/getOrtWasmPaths.ts index 68fc5df56f..85c13ac277 100644 --- a/packages/ai/src/utils/getOrtWasmPaths.ts +++ b/packages/ai/src/utils/getOrtWasmPaths.ts @@ -22,13 +22,31 @@ * `index.html`, and ONNX dies with `expected magic word 00 61 73 6d, found * 3c 21 64 6f` followed by "no available backend found". * - * So resolve the prefix against the base the bundler already uses for the - * assets it emits, which is the directory the copy lives in. + * So resolve the prefix against where the *application* is served from, never + * against the current document URL. In order of authority: + * + * 1. the bundler's own asset base, when it exposes one — the examples and the + * docs site rely on this, since the copy of `onnxruntime-web/dist` sits + * beside the emitted bundle rather than at the server root; + * 2. `PUBLIC_URL`, the base applications inject for exactly this purpose; + * 3. `document.baseURI`, but only when the page carries an explicit + * `` — that element *is* a declaration of the application root, + * whereas a bare `document.baseURI` is just the route; + * 4. `'/'`, the server root, which is where a copy next to the bundle lands + * for an application served from the root — the load path a page one + * segment deep already resolved `'ort/'` to. */ /** Directory applications copy `onnxruntime-web/dist` into. */ export const DEFAULT_ORT_WASM_DIRECTORY = 'ort/'; +/** + * Public base assumed when nothing declares one. Applications are served from + * the root far more often than from a sub-path, and a wrong guess here is a + * 404 rather than a wasm binary — so guess the common case. + */ +export const DEFAULT_PUBLIC_URL = '/'; + /** * webpack and rspack replace this identifier with the bundle's runtime public * path (`output.publicPath` / `assetPrefix`, or the script's own directory @@ -37,6 +55,20 @@ export const DEFAULT_ORT_WASM_DIRECTORY = 'ort/'; */ declare const __webpack_public_path__: string | undefined; +/** + * Declared for the same reason: `process` does not exist in a browser, and + * bundlers that do substitute `process.env.PUBLIC_URL` do it by matching that + * exact expression, so it has to be spelled out rather than reached through + * `globalThis`. + */ +declare const process: { env: Record } | undefined; + +/** + * The base a bundler anchors its emitted asset URLs to. This is the definition + * webpack and rspack generate for `__webpack_require__.b`, which is what + * `new URL(, import.meta.url)` compiles down to — so the runtime + * binaries end up resolved against the same base as the codec wasm. + */ function getBundlePublicPath(): string | undefined { return typeof __webpack_public_path__ === 'string' && __webpack_public_path__ ? __webpack_public_path__ @@ -44,40 +76,86 @@ function getBundlePublicPath(): string | undefined { } /** - * The base a bundler anchors its emitted asset URLs to. This is the definition - * webpack and rspack generate for `__webpack_require__.b`, which is what - * `new URL(, import.meta.url)` compiles down to — so the runtime - * binaries end up resolved against the same base as the codec wasm. + * The public base the application injected: `process.env.PUBLIC_URL` for a + * build-time substitution (Create React App and friends), `PUBLIC_URL` on the + * global for a runtime one — the spelling `utils/demo/helpers/initDemo.ts` + * already uses for `dicom-microscopy-viewer`. */ -function getDocumentBase(): string | undefined { +function getInjectedPublicUrl(): string | undefined { + const injected = + (typeof process !== 'undefined' && process.env.PUBLIC_URL) || + (globalThis as { PUBLIC_URL?: string }).PUBLIC_URL; + + return typeof injected === 'string' && injected ? injected : undefined; +} + +/** + * `document.baseURI`, but only when a `` element put it there. + * Without that element `baseURI` is just the current route, which is the thing + * this module exists to stop resolving against. + */ +function getExplicitDocumentBase(): string | undefined { + if (typeof document === 'undefined' || !document.querySelector) { + return undefined; + } + + return document.querySelector('base[href]') + ? document.baseURI || undefined + : undefined; +} + +/** + * Something absolute to anchor a path-only base (`/pacs/`) against. Only its + * origin survives that resolution — the route never does. + */ +function getAbsoluteReference(): string | undefined { return ( (typeof document !== 'undefined' && document.baseURI) || globalThis.location?.href ); } +/** + * Where the application is served from, in the order documented above. + * + * The result always names a directory. `PUBLIC_URL=/pacs` is a common + * spelling, and URL resolution would treat that last segment as a file and + * discard it — turning `/pacs/ort/` back into `/ort/`. + */ +function getApplicationBase(): string { + const base = + getBundlePublicPath() ?? + getInjectedPublicUrl() ?? + getExplicitDocumentBase() ?? + DEFAULT_PUBLIC_URL; + + return base.endsWith('/') ? base : `${base}/`; +} + /** * Absolute URL prefix for the ONNX Runtime wasm binaries. * * @param directory - directory holding `onnxruntime-web/dist`, relative to the - * application. Defaults to `ort/`. + * application. An absolute path or a full URL is used as given, so an + * application serving the binaries from a CDN or a versioned path can say + * so. Defaults to `ort/`. * @returns the prefix as an absolute URL, or `directory` unchanged when there * is nothing to resolve it against (a non-browser context). */ export default function getOrtWasmPaths( directory = DEFAULT_ORT_WASM_DIRECTORY ): string { - const documentBase = getDocumentBase(); - const base = getBundlePublicPath() ?? documentBase; - - if (!base || !documentBase) { - return directory; - } + const applicationBase = getApplicationBase(); + const reference = getAbsoluteReference(); try { - // The public path is rarely a full URL (`/pacs/`, or `auto` in a worker), - // so anchor it before the directory is resolved against it. - return new URL(directory, new URL(base, documentBase)).href; + // The application base is rarely a full URL (`/pacs/`, or `'auto'` in a + // worker), so anchor it before the directory is resolved against it. + const base = reference + ? new URL(applicationBase, reference) + : new URL(applicationBase); + + return new URL(directory, base).href; } catch { return directory; } From b60e59e3773cdef0aa0720f74c12c1691dbbc3c5 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Mon, 17 Aug 2026 14:24:11 -0400 Subject: [PATCH 4/6] fix(ai): locate the ONNX wasm from the codec wasm path, then PUBLIC_URL `ort.env.wasm.wasmPaths = "ort/"` is document-relative, so it resolves against the current route rather than against the application. It finds the copy of `onnxruntime-web/dist` only when the page sits exactly one segment deep; a viewer served from /viewer/dicomweb requests /viewer/ort/ort-wasm-*.wasm, receives the SPA fallback index.html, and ONNX fails with "expected magic word 00 61 73 6d, found 3c 21 64 6f" followed by "no available backend found". Resolve the prefix in the order an application declares it: 1. the system-level wasm directory, when one is set. Applications that already serve their codec binaries out of one place name it once with `init({ wasmBasePath })` on the DICOM image loader, and the ONNX Runtime binaries load from there too - no second setting. 2. otherwise `PUBLIC_URL` (`window.PUBLIC_URL`, `window.config.path`, or the build-time `process.env.PUBLIC_URL`), defaulting to "/". 3. with `ort/` resolved against that base, anchored at the page origin - protocol and host, never the route. This is the formula dicom-microscopy-viewer has always used for PUBLIC_URL, so existing deployments with or without PUBLIC_URL keep working. The codec wasm directory was private to the DICOM image loader, so move the value into `@cornerstonejs/core` where every package can honour it: `utilities.setWasmBasePath` / `getWasmBasePath`, written by setOptions on the loader and read back as the fallback in createImage, so setting it either way reaches both the codecs and ONNX. The examples serve `ort/` beside the page - at the root under the example dev server, under /live-examples/ on the docs site - so initDemo declares the page directory as PUBLIC_URL, which is what the route-relative prefix used to resolve to in both places. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ai/src/ONNXSegmentationController.ts | 8 +- packages/ai/src/index.ts | 10 ++ packages/ai/src/utils/getOrtWasmPaths.test.ts | 75 +++++--- packages/ai/src/utils/getOrtWasmPaths.ts | 170 ++++++++---------- packages/ai/tsconfig.json | 3 +- packages/core/src/utilities/index.ts | 3 + packages/core/src/utilities/wasmBasePath.ts | 36 ++++ .../src/imageLoader/createImage.ts | 6 +- .../src/imageLoader/internal/options.ts | 9 + .../getting-started/vue-angular-react-vite.md | 2 + utils/demo/helpers/initDemo.ts | 12 +- 11 files changed, 206 insertions(+), 128 deletions(-) create mode 100644 packages/core/src/utilities/wasmBasePath.ts diff --git a/packages/ai/src/ONNXSegmentationController.ts b/packages/ai/src/ONNXSegmentationController.ts index e5c0683bbe..906f9ea8e6 100644 --- a/packages/ai/src/ONNXSegmentationController.ts +++ b/packages/ai/src/ONNXSegmentationController.ts @@ -1642,10 +1642,10 @@ export default class ONNXSegmentationController { } config.threads = parseInt(String(config.threads)); config.local = parseInt(config.local); - // Leave a location the application configured alone — it may be serving - // the binaries from a CDN or a versioned path. Otherwise resolve the - // copy of `onnxruntime-web/dist` against the application rather than - // against the current route. See `getOrtWasmPaths`. + // Leave a location the application assigned alone. Otherwise take the + // system-level wasm directory, or resolve the copy of + // `onnxruntime-web/dist` against the application rather than against the + // current route. See `getOrtWasmPaths`. if (!ort.env.wasm.wasmPaths) { ort.env.wasm.wasmPaths = getOrtWasmPaths(); } diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 253080862d..7f017a5a4d 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -2,10 +2,20 @@ import ONNXSegmentationController from './ONNXSegmentationController'; import LabelmapSlicePropagationTool from './LabelmapSlicePropagationTool'; import MarkerLabelmapTool from './MarkerLabelmapTool'; import { Events } from './enums'; +import getOrtWasmPaths, { + DEFAULT_ORT_WASM_DIRECTORY, + DEFAULT_PUBLIC_URL, +} from './utils/getOrtWasmPaths'; export { ONNXSegmentationController, LabelmapSlicePropagationTool, MarkerLabelmapTool, Events, + // Exported so an application can put the ONNX Runtime binaries somewhere + // this cannot work out for itself - a CDN, a versioned path - by assigning + // `ort.env.wasm.wasmPaths` before the controller runs. + getOrtWasmPaths, + DEFAULT_ORT_WASM_DIRECTORY, + DEFAULT_PUBLIC_URL, }; diff --git a/packages/ai/src/utils/getOrtWasmPaths.test.ts b/packages/ai/src/utils/getOrtWasmPaths.test.ts index c9a29c8a25..1547b3825c 100644 --- a/packages/ai/src/utils/getOrtWasmPaths.test.ts +++ b/packages/ai/src/utils/getOrtWasmPaths.test.ts @@ -1,8 +1,9 @@ +import { utilities } from '@cornerstonejs/core'; import getOrtWasmPaths from './getOrtWasmPaths'; type PublicUrlGlobal = typeof globalThis & { PUBLIC_URL?: string; - __webpack_public_path__?: string; + config?: { path?: string }; }; const publicUrlGlobal = globalThis as PublicUrlGlobal; @@ -12,20 +13,14 @@ function navigateTo(route: string) { window.history.replaceState(null, '', route); } -function addBaseElement(href: string) { - const base = document.createElement('base'); - base.setAttribute('href', href); - document.head.appendChild(base); -} - describe('getOrtWasmPaths', () => { const publicUrl = process.env.PUBLIC_URL; beforeEach(() => { delete publicUrlGlobal.PUBLIC_URL; - delete publicUrlGlobal.__webpack_public_path__; + delete publicUrlGlobal.config; delete process.env.PUBLIC_URL; - document.head.querySelectorAll('base').forEach((base) => base.remove()); + utilities.setWasmBasePath(undefined); navigateTo('/'); }); @@ -63,6 +58,19 @@ describe('getOrtWasmPaths', () => { expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); }); + it('accepts the base from a viewer configuration object', () => { + publicUrlGlobal.config = { path: '/pacs/' }; + + expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); + }); + + it('prefers a runtime PUBLIC_URL over the build-time one', () => { + publicUrlGlobal.PUBLIC_URL = '/runtime/'; + process.env.PUBLIC_URL = '/build/'; + + expect(getOrtWasmPaths()).toBe('http://localhost/runtime/ort/'); + }); + it('tolerates a PUBLIC_URL without its trailing slash', () => { process.env.PUBLIC_URL = '/pacs'; navigateTo('/pacs/viewer/dicomweb'); @@ -70,27 +78,48 @@ describe('getOrtWasmPaths', () => { expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); }); - it('prefers the bundler public path over PUBLIC_URL', () => { - // The examples and the docs site copy onnxruntime-web/dist beside the - // emitted bundle, which is what the bundler public path points at. - publicUrlGlobal.__webpack_public_path__ = 'http://cdn.example.com/app/'; - process.env.PUBLIC_URL = '/pacs/'; + it('uses a full URL PUBLIC_URL as given', () => { + publicUrlGlobal.PUBLIC_URL = 'http://cdn.example.com/app/'; expect(getOrtWasmPaths()).toBe('http://cdn.example.com/app/ort/'); }); - it('ignores an empty bundler public path', () => { - publicUrlGlobal.__webpack_public_path__ = ''; - process.env.PUBLIC_URL = '/pacs/'; + describe('with the system-level wasm directory set', () => { + it('loads the binaries from it, the way the codecs do', () => { + utilities.setWasmBasePath('/assets/cs-wasm/'); + navigateTo('/viewer/dicomweb'); - expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); - }); + expect(getOrtWasmPaths()).toBe('http://localhost/assets/cs-wasm/'); + }); - it('honours an explicit base element from a deep route', () => { - addBaseElement('http://localhost/viewer/'); - navigateTo('/viewer/dicomweb/studies/1.2.3'); + it('takes it in preference to PUBLIC_URL', () => { + utilities.setWasmBasePath('https://cdn.example.com/wasm/'); + process.env.PUBLIC_URL = '/pacs/'; + + expect(getOrtWasmPaths()).toBe('https://cdn.example.com/wasm/'); + }); + + it('adds the trailing slash it may be missing', () => { + utilities.setWasmBasePath('/assets/cs-wasm'); + + expect(getOrtWasmPaths()).toBe('http://localhost/assets/cs-wasm/'); + }); + + it('resolves a relative one against the application base', () => { + utilities.setWasmBasePath('cs-wasm/'); + process.env.PUBLIC_URL = '/pacs/'; + navigateTo('/pacs/viewer/dicomweb'); + + expect(getOrtWasmPaths()).toBe('http://localhost/pacs/cs-wasm/'); + }); + + it('is overridden by a directory the caller names', () => { + utilities.setWasmBasePath('/assets/cs-wasm/'); - expect(getOrtWasmPaths()).toBe('http://localhost/viewer/ort/'); + expect(getOrtWasmPaths('ort-1.17.1/')).toBe( + 'http://localhost/ort-1.17.1/' + ); + }); }); it('uses an application-supplied directory as given', () => { diff --git a/packages/ai/src/utils/getOrtWasmPaths.ts b/packages/ai/src/utils/getOrtWasmPaths.ts index 85c13ac277..a19b9d82ed 100644 --- a/packages/ai/src/utils/getOrtWasmPaths.ts +++ b/packages/ai/src/utils/getOrtWasmPaths.ts @@ -5,9 +5,9 @@ * * Every other wasm binary in this repository is located with * `new URL(, import.meta.url)` — the bundler resolves the file, - * emits it, and hands back an absolute URL that does not depend on the page - * the user is currently on. `onnxruntime-web@1.17` cannot be addressed that - * way: its `exports` map publishes only the JavaScript entry points, so + * emits it, and hands back an absolute URL that does not depend on the page the + * user is currently on. `onnxruntime-web@1.17` cannot be addressed that way: + * its `exports` map publishes only the JavaScript entry points, so * `new URL('onnxruntime-web/dist/ort-wasm-simd.jsep.wasm', import.meta.url)` * fails to resolve. Applications copy `onnxruntime-web/dist` next to their * bundle instead — the example runner copies it to `/ort` @@ -23,140 +23,116 @@ * 3c 21 64 6f` followed by "no available backend found". * * So resolve the prefix against where the *application* is served from, never - * against the current document URL. In order of authority: + * against the current document URL: * - * 1. the bundler's own asset base, when it exposes one — the examples and the - * docs site rely on this, since the copy of `onnxruntime-web/dist` sits - * beside the emitted bundle rather than at the server root; - * 2. `PUBLIC_URL`, the base applications inject for exactly this purpose; - * 3. `document.baseURI`, but only when the page carries an explicit - * `` — that element *is* a declaration of the application root, - * whereas a bare `document.baseURI` is just the route; - * 4. `'/'`, the server root, which is where a copy next to the bundle lands - * for an application served from the root — the load path a page one - * segment deep already resolved `'ort/'` to. + * 1. when the system-level wasm directory is set, these binaries live there, + * exactly like the codec binaries do — an application that already serves + * its wasm out of one place (`init({ wasmBasePath })` on + * `@cornerstonejs/dicom-image-loader`) does not need a second setting; + * 2. otherwise `PUBLIC_URL`, the base an application declares for itself, + * defaulting to `'/'` when nothing declares one; + * 3. with the directory resolved against that base, anchored at the page's + * origin — the protocol and host of `window.location` and nothing more, + * which is how `dicom-microscopy-viewer` has always located its own assets + * from `PUBLIC_URL`. The route never takes part. */ +import { utilities } from '@cornerstonejs/core'; /** Directory applications copy `onnxruntime-web/dist` into. */ export const DEFAULT_ORT_WASM_DIRECTORY = 'ort/'; /** - * Public base assumed when nothing declares one. Applications are served from - * the root far more often than from a sub-path, and a wrong guess here is a - * 404 rather than a wasm binary — so guess the common case. + * Base assumed when nothing declares one. Applications are served from the root + * far more often than from a sub-path, and it is the same default every other + * reader of `PUBLIC_URL` picks. */ export const DEFAULT_PUBLIC_URL = '/'; /** - * webpack and rspack replace this identifier with the bundle's runtime public - * path (`output.publicPath` / `assetPrefix`, or the script's own directory - * when that is `'auto'`). It is declared rather than imported because other - * bundlers leave it undefined — the `typeof` guard below covers them. + * Declared because `process` does not exist in a browser, and the bundlers that + * substitute `process.env.PUBLIC_URL` do it by matching that exact expression — + * so it has to be spelled out rather than reached through `globalThis`. */ -declare const __webpack_public_path__: string | undefined; +declare const process: { env: Record }; -/** - * Declared for the same reason: `process` does not exist in a browser, and - * bundlers that do substitute `process.env.PUBLIC_URL` do it by matching that - * exact expression, so it has to be spelled out rather than reached through - * `globalThis`. - */ -declare const process: { env: Record } | undefined; - -/** - * The base a bundler anchors its emitted asset URLs to. This is the definition - * webpack and rspack generate for `__webpack_require__.b`, which is what - * `new URL(, import.meta.url)` compiles down to — so the runtime - * binaries end up resolved against the same base as the codec wasm. - */ -function getBundlePublicPath(): string | undefined { - return typeof __webpack_public_path__ === 'string' && __webpack_public_path__ - ? __webpack_public_path__ - : undefined; +/** Trailing slash, so URL resolution treats the value as a directory. */ +function asDirectory(path: string): string { + return path.endsWith('/') ? path : `${path}/`; } /** - * The public base the application injected: `process.env.PUBLIC_URL` for a - * build-time substitution (Create React App and friends), `PUBLIC_URL` on the - * global for a runtime one — the spelling `utils/demo/helpers/initDemo.ts` - * already uses for `dicom-microscopy-viewer`. - */ -function getInjectedPublicUrl(): string | undefined { - const injected = - (typeof process !== 'undefined' && process.env.PUBLIC_URL) || - (globalThis as { PUBLIC_URL?: string }).PUBLIC_URL; - - return typeof injected === 'string' && injected ? injected : undefined; -} - -/** - * `document.baseURI`, but only when a `` element put it there. - * Without that element `baseURI` is just the current route, which is the thing - * this module exists to stop resolving against. + * `PUBLIC_URL` as a build-time substitution (Create React App and friends). + * + * A `typeof process !== 'undefined'` guard would be the obvious way to write + * this and does not work: bundlers rewrite `process.env.PUBLIC_URL` but leave + * the `typeof` check alone, and it is false in every browser bundle, so the + * substituted value would never be read. Catching the reference error is what + * is left. */ -function getExplicitDocumentBase(): string | undefined { - if (typeof document === 'undefined' || !document.querySelector) { +function getBuildTimePublicUrl(): string | undefined { + try { + return process.env.PUBLIC_URL || undefined; + } catch { return undefined; } - - return document.querySelector('base[href]') - ? document.baseURI || undefined - : undefined; } /** - * Something absolute to anchor a path-only base (`/pacs/`) against. Only its - * origin survives that resolution — the route never does. + * The base the application declares for itself. `PUBLIC_URL` on the global is + * the runtime spelling `utils/demo/helpers/initDemo.ts` sets and + * `dicom-microscopy-viewer` reads; `config.path` is the same value carried in a + * viewer's configuration object. */ -function getAbsoluteReference(): string | undefined { +function getPublicUrl(): string { + const globals = globalThis as { + PUBLIC_URL?: string; + config?: { path?: string }; + }; + return ( - (typeof document !== 'undefined' && document.baseURI) || - globalThis.location?.href + globals.PUBLIC_URL || + globals.config?.path || + getBuildTimePublicUrl() || + DEFAULT_PUBLIC_URL ); } /** - * Where the application is served from, in the order documented above. - * - * The result always names a directory. `PUBLIC_URL=/pacs` is a common - * spelling, and URL resolution would treat that last segment as a file and - * discard it — turning `/pacs/ort/` back into `/ort/`. + * The page's origin, and nothing else. `PUBLIC_URL` is usually a path + * (`/pacs/`) and needs something absolute to resolve against, but the route is + * exactly what this module exists to keep out of the result — so hand over the + * protocol and host alone rather than `location.href` or `document.baseURI`. */ -function getApplicationBase(): string { - const base = - getBundlePublicPath() ?? - getInjectedPublicUrl() ?? - getExplicitDocumentBase() ?? - DEFAULT_PUBLIC_URL; +function getOrigin(): string | undefined { + const { location } = globalThis; - return base.endsWith('/') ? base : `${base}/`; + return location ? `${location.protocol}//${location.host}` : undefined; } /** * Absolute URL prefix for the ONNX Runtime wasm binaries. * - * @param directory - directory holding `onnxruntime-web/dist`, relative to the - * application. An absolute path or a full URL is used as given, so an - * application serving the binaries from a CDN or a versioned path can say - * so. Defaults to `ort/`. - * @returns the prefix as an absolute URL, or `directory` unchanged when there + * @param directory - directory holding `onnxruntime-web/dist`. Resolved against + * the application base, so `'assets/ort/'` is relative to the application, + * `'/ort/'` to the server root, and a full URL is used as given — an + * application serving the binaries from a CDN or a versioned path can say so. + * Passing this overrides the system-level wasm directory. Defaults to the + * system-level directory when one is set, otherwise to `ort/`. + * @returns the prefix as an absolute URL, or the directory unchanged when there * is nothing to resolve it against (a non-browser context). */ -export default function getOrtWasmPaths( - directory = DEFAULT_ORT_WASM_DIRECTORY -): string { - const applicationBase = getApplicationBase(); - const reference = getAbsoluteReference(); +export default function getOrtWasmPaths(directory?: string): string { + const prefix = asDirectory( + directory ?? utilities.getWasmBasePath() ?? DEFAULT_ORT_WASM_DIRECTORY + ); + const publicUrl = asDirectory(getPublicUrl()); + const origin = getOrigin(); try { - // The application base is rarely a full URL (`/pacs/`, or `'auto'` in a - // worker), so anchor it before the directory is resolved against it. - const base = reference - ? new URL(applicationBase, reference) - : new URL(applicationBase); + const base = origin ? new URL(publicUrl, origin) : new URL(publicUrl); - return new URL(directory, base).href; + return new URL(prefix, base).href; } catch { - return directory; + return prefix; } } diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json index bc915f1e65..a954f9c713 100644 --- a/packages/ai/tsconfig.json +++ b/packages/ai/tsconfig.json @@ -4,5 +4,6 @@ "outDir": "./dist/esm", "rootDir": "./src" }, - "include": ["./src/**/*"] + "include": ["./src/**/*"], + "exclude": ["./src/**/*.spec.ts", "./src/**/*.test.ts"] } diff --git a/packages/core/src/utilities/index.ts b/packages/core/src/utilities/index.ts index 3ee7e1af25..f005ce6848 100644 --- a/packages/core/src/utilities/index.ts +++ b/packages/core/src/utilities/index.ts @@ -140,6 +140,7 @@ import { viewportIsInStackMode, } from './viewportCapabilities'; import { getNormalizedAspectRatio } from './getNormalizedAspectRatio'; +import { getWasmBasePath, setWasmBasePath } from './wasmBasePath'; export { updatePlaneRestriction } from './updatePlaneRestriction'; const getViewportModality = (viewport: IViewport, volumeId?: string) => _getViewportModality(viewport, volumeId, cache.getVolume); @@ -276,4 +277,6 @@ export { viewportIsInVolumeMode, viewportIsInStackMode, getNormalizedAspectRatio, + getWasmBasePath, + setWasmBasePath, }; diff --git a/packages/core/src/utilities/wasmBasePath.ts b/packages/core/src/utilities/wasmBasePath.ts new file mode 100644 index 0000000000..27a38bc796 --- /dev/null +++ b/packages/core/src/utilities/wasmBasePath.ts @@ -0,0 +1,36 @@ +/** + * System-level location of the WebAssembly binaries Cornerstone loads at + * runtime. + * + * The codec binaries the DICOM image loader decodes with are the reason this + * exists. Each decoder resolves its binary against a bare + * `@cornerstonejs/codec-...` specifier, which bundlers do not rewrite, so a + * bundled application copies the binaries somewhere it serves and names that + * directory once — `init({ wasmBasePath })` on the loader, see + * `LoaderOptions.wasmBasePath`. That option is recorded here so it is not + * private to the loader: any package that has to locate a wasm binary honours + * the same directory, which is how `@cornerstonejs/ai` finds the ONNX Runtime + * binaries (see `getOrtWasmPaths`). + * + * It is deliberately one directory for every binary rather than a path per + * consumer — applications serve them all out of a single place. + */ + +/** Directory every wasm binary is loaded from, or undefined for the default. */ +let wasmBasePath: string | undefined; + +/** + * Sets the directory every wasm binary is loaded from. Pass undefined (or an + * empty string) to restore each consumer's own default resolution. + * + * `init({ wasmBasePath })` on `@cornerstonejs/dicom-image-loader` calls this, + * so applications configuring the loader do not need to call it themselves. + */ +export function setWasmBasePath(basePath?: string): void { + wasmBasePath = basePath || undefined; +} + +/** The configured wasm directory, or undefined when nothing has set one. */ +export function getWasmBasePath(): string | undefined { + return wasmBasePath; +} diff --git a/packages/dicomImageLoader/src/imageLoader/createImage.ts b/packages/dicomImageLoader/src/imageLoader/createImage.ts index 1b33cc4db2..7605c93a0f 100644 --- a/packages/dicomImageLoader/src/imageLoader/createImage.ts +++ b/packages/dicomImageLoader/src/imageLoader/createImage.ts @@ -86,7 +86,11 @@ async function createImage( } } - const { decodeConfig, wasmBasePath } = getOptions(); + // `setOptions` publishes the loader option system-wide, so the two agree; + // reading the system value as the fallback also picks up a directory set + // through `utilities.setWasmBasePath` alone. + const { decodeConfig, wasmBasePath = utilities.getWasmBasePath() } = + getOptions(); // Forward the WASM base path to the worker, where the decoders resolve their // binaries. The loader-level option wins over one set in decodeConfig. const taskDecodeConfig = diff --git a/packages/dicomImageLoader/src/imageLoader/internal/options.ts b/packages/dicomImageLoader/src/imageLoader/internal/options.ts index 31b6850f86..8070827126 100644 --- a/packages/dicomImageLoader/src/imageLoader/internal/options.ts +++ b/packages/dicomImageLoader/src/imageLoader/internal/options.ts @@ -1,3 +1,4 @@ +import { utilities } from '@cornerstonejs/core'; import type { LoaderOptions } from '../../types'; let options: LoaderOptions = { @@ -22,6 +23,14 @@ let options: LoaderOptions = { export function setOptions(newOptions: LoaderOptions): void { options = Object.assign(options, newOptions); + + // The wasm directory is not private to this loader: it is where every + // Cornerstone package looks for its binaries, so publish it system-wide. + // Options without the key leave the current value alone, matching the + // decode-config behaviour in `shared/wasmBasePath`. + if (newOptions.wasmBasePath !== undefined) { + utilities.setWasmBasePath(newOptions.wasmBasePath); + } } export function getOptions(): LoaderOptions { diff --git a/packages/docs/docs/getting-started/vue-angular-react-vite.md b/packages/docs/docs/getting-started/vue-angular-react-vite.md index ef45d1ba23..e1e48aa005 100644 --- a/packages/docs/docs/getting-started/vue-angular-react-vite.md +++ b/packages/docs/docs/getting-started/vue-angular-react-vite.md @@ -151,6 +151,8 @@ dicomImageLoaderInit({ A relative `wasmBasePath` resolves against the decode worker's location, and an absolute path or full URL (e.g. a CDN) is used as given. When the option is unset, the default `import.meta.url` resolution applies, which is what unbundled and script-tag usage relies on. +The path is system-wide rather than loader-specific, so it is also where `@cornerstonejs/ai` looks for the ONNX Runtime binaries — copy `onnxruntime-web/dist` into the same directory and there is nothing further to configure. With no `wasmBasePath` set, those binaries are expected in `ort/` under the application's base, which is taken from `PUBLIC_URL` (`window.PUBLIC_URL`, `window.config.path` or the build-time `process.env.PUBLIC_URL`) and defaults to the server root. A subpath deployment that does not set `wasmBasePath` should therefore declare `PUBLIC_URL`; either way the location no longer depends on the route the user happens to be on. + --- ## Vite diff --git a/utils/demo/helpers/initDemo.ts b/utils/demo/helpers/initDemo.ts index 8f940e5e15..48b34cb0c7 100644 --- a/utils/demo/helpers/initDemo.ts +++ b/utils/demo/helpers/initDemo.ts @@ -24,6 +24,15 @@ import { window.cornerstone = cornerstone; window.cornerstoneTools = cornerstoneTools; +// Examples are served from the root by the example dev server and from +// /live-examples/ on the docs site, and each deployment copies the wasm +// binaries it needs (onnxruntime-web, dicom-microscopy-viewer) next to the +// page. Declaring the page's own directory as the public URL is what makes +// those copies findable from either location; a page that declares its own +// PUBLIC_URL keeps it. Examples are single pages rather than routed +// applications, so the page directory *is* the application root here. +window.PUBLIC_URL ||= window.location.pathname.replace(/[^/]*$/, ''); + export default async function initDemo(config: any = {}) { const urlParams = new URLSearchParams(window.location.search); const debugEnabled = urlParams.get('debug') === 'true'; @@ -78,8 +87,7 @@ export default async function initDemo(config: any = {}) { */ export async function peerImport(moduleId) { if (moduleId === 'dicom-microscopy-viewer') { - // The microscopy viewer loads relative to the public URL - window.PUBLIC_URL ||= '/'; + // The microscopy viewer loads relative to the public URL, declared above. // Use a relative library path that includes the component name window.PUBLIC_LIB_URL ||= './${component}/'; return importGlobal( From 277308deac38268e989b70e12ec32ee432200675 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Mon, 17 Aug 2026 14:49:45 -0400 Subject: [PATCH 5/6] refactor(core): resolve wasm locations in core, not in the ai package Locating a wasm binary is a standard operation - the codecs and the ONNX Runtime have the same problem for the same reason - so the resolution belongs beside the base path it reads, not in the one package that happened to need it first. `utilities.resolveWasmBasePath(defaultDirectory)` now answers the whole question: the configured `wasmBasePath` when there is one, otherwise the standard directory its owner copies the binaries into, resolved against the application. `utilities.resolveApplicationUrl(path)` is the general half underneath it, with `getPublicUrl` alongside - resolving a path against `PUBLIC_URL` and the page origin rather than against the current route is not specific to wasm either. `getOrtWasmPaths` keeps only what is specific to the ONNX Runtime: the standard `ort/` directory name, and the choice to let a caller-named directory outrank the configured one. `DEFAULT_PUBLIC_URL` moves with the logic and is no longer re-exported from `@cornerstonejs/ai`. The resolution tests move to `packages/core/test/wasmBasePath.jest.js`, covering both resolvers and every `PUBLIC_URL` source; what is left in `packages/ai` is the three decisions that module still makes. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ai/src/index.ts | 6 +- packages/ai/src/utils/getOrtWasmPaths.test.ts | 130 ++---------- packages/ai/src/utils/getOrtWasmPaths.ts | 126 ++--------- packages/core/src/utilities/index.ts | 10 +- .../src/utilities/resolveApplicationUrl.ts | 100 +++++++++ packages/core/src/utilities/wasmBasePath.ts | 56 +++-- packages/core/test/wasmBasePath.jest.js | 195 ++++++++++++++++++ 7 files changed, 387 insertions(+), 236 deletions(-) create mode 100644 packages/core/src/utilities/resolveApplicationUrl.ts create mode 100644 packages/core/test/wasmBasePath.jest.js diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 7f017a5a4d..5e581ceb86 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -4,7 +4,6 @@ import MarkerLabelmapTool from './MarkerLabelmapTool'; import { Events } from './enums'; import getOrtWasmPaths, { DEFAULT_ORT_WASM_DIRECTORY, - DEFAULT_PUBLIC_URL, } from './utils/getOrtWasmPaths'; export { @@ -14,8 +13,9 @@ export { Events, // Exported so an application can put the ONNX Runtime binaries somewhere // this cannot work out for itself - a CDN, a versioned path - by assigning - // `ort.env.wasm.wasmPaths` before the controller runs. + // `ort.env.wasm.wasmPaths` before the controller runs. Serving them from the + // same directory as the codec binaries needs nothing from here: set that + // directory once, with `init({ wasmBasePath })` on the DICOM image loader. getOrtWasmPaths, DEFAULT_ORT_WASM_DIRECTORY, - DEFAULT_PUBLIC_URL, }; diff --git a/packages/ai/src/utils/getOrtWasmPaths.test.ts b/packages/ai/src/utils/getOrtWasmPaths.test.ts index 1547b3825c..cc5f3658c7 100644 --- a/packages/ai/src/utils/getOrtWasmPaths.test.ts +++ b/packages/ai/src/utils/getOrtWasmPaths.test.ts @@ -1,27 +1,20 @@ import { utilities } from '@cornerstonejs/core'; import getOrtWasmPaths from './getOrtWasmPaths'; -type PublicUrlGlobal = typeof globalThis & { - PUBLIC_URL?: string; - config?: { path?: string }; -}; - -const publicUrlGlobal = globalThis as PublicUrlGlobal; - -/** Puts the document on `route` the way a client-side router would. */ -function navigateTo(route: string) { - window.history.replaceState(null, '', route); -} - +/** + * How a base is resolved is `resolveApplicationUrl`/`resolveWasmBasePath` in + * `@cornerstonejs/core`, covered by `packages/core/test/wasmBasePath.jest.js`. + * What is left here is what this module decides: the standard directory, and + * which of the two resolvers a given call goes through. + */ describe('getOrtWasmPaths', () => { const publicUrl = process.env.PUBLIC_URL; beforeEach(() => { - delete publicUrlGlobal.PUBLIC_URL; - delete publicUrlGlobal.config; + delete (globalThis as { PUBLIC_URL?: string }).PUBLIC_URL; delete process.env.PUBLIC_URL; utilities.setWasmBasePath(undefined); - navigateTo('/'); + window.history.replaceState(null, '', '/'); }); afterAll(() => { @@ -32,113 +25,28 @@ describe('getOrtWasmPaths', () => { } }); - it('resolves against the server root when nothing declares a base', () => { - expect(getOrtWasmPaths()).toBe('http://localhost/ort/'); - }); - - it('resolves against the application root from a deep route', () => { - // The bug this guards: `'ort/'` used to resolve against the route, so a - // viewer on /viewer/dicomweb fetched /viewer/ort/ and got index.html back. - navigateTo('/viewer/dicomweb/studies/1.2.3'); - - expect(getOrtWasmPaths()).toBe('http://localhost/ort/'); - }); - - it('keeps the same relative location for a sub-path build', () => { + it('looks in ort/ under the application base', () => { process.env.PUBLIC_URL = '/pacs/'; - navigateTo('/pacs/viewer/dicomweb'); - - expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); - }); - - it('accepts a runtime PUBLIC_URL on the global', () => { - publicUrlGlobal.PUBLIC_URL = '/pacs/'; - navigateTo('/pacs/viewer/dicomweb'); - - expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); - }); - - it('accepts the base from a viewer configuration object', () => { - publicUrlGlobal.config = { path: '/pacs/' }; - - expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); - }); - - it('prefers a runtime PUBLIC_URL over the build-time one', () => { - publicUrlGlobal.PUBLIC_URL = '/runtime/'; - process.env.PUBLIC_URL = '/build/'; - - expect(getOrtWasmPaths()).toBe('http://localhost/runtime/ort/'); - }); - - it('tolerates a PUBLIC_URL without its trailing slash', () => { - process.env.PUBLIC_URL = '/pacs'; - navigateTo('/pacs/viewer/dicomweb'); + // The bug this guards: `'ort/'` used to resolve against the route, so a + // viewer on /pacs/viewer/dicomweb fetched /pacs/viewer/ort/ and got + // index.html back. + window.history.replaceState(null, '', '/pacs/viewer/dicomweb'); expect(getOrtWasmPaths()).toBe('http://localhost/pacs/ort/'); }); - it('uses a full URL PUBLIC_URL as given', () => { - publicUrlGlobal.PUBLIC_URL = 'http://cdn.example.com/app/'; + it('loads from the configured wasm directory, the way the codecs do', () => { + utilities.setWasmBasePath('/assets/cs-wasm/'); - expect(getOrtWasmPaths()).toBe('http://cdn.example.com/app/ort/'); + expect(getOrtWasmPaths()).toBe('http://localhost/assets/cs-wasm/'); }); - describe('with the system-level wasm directory set', () => { - it('loads the binaries from it, the way the codecs do', () => { - utilities.setWasmBasePath('/assets/cs-wasm/'); - navigateTo('/viewer/dicomweb'); - - expect(getOrtWasmPaths()).toBe('http://localhost/assets/cs-wasm/'); - }); - - it('takes it in preference to PUBLIC_URL', () => { - utilities.setWasmBasePath('https://cdn.example.com/wasm/'); - process.env.PUBLIC_URL = '/pacs/'; - - expect(getOrtWasmPaths()).toBe('https://cdn.example.com/wasm/'); - }); - - it('adds the trailing slash it may be missing', () => { - utilities.setWasmBasePath('/assets/cs-wasm'); - - expect(getOrtWasmPaths()).toBe('http://localhost/assets/cs-wasm/'); - }); - - it('resolves a relative one against the application base', () => { - utilities.setWasmBasePath('cs-wasm/'); - process.env.PUBLIC_URL = '/pacs/'; - navigateTo('/pacs/viewer/dicomweb'); - - expect(getOrtWasmPaths()).toBe('http://localhost/pacs/cs-wasm/'); - }); - - it('is overridden by a directory the caller names', () => { - utilities.setWasmBasePath('/assets/cs-wasm/'); - - expect(getOrtWasmPaths('ort-1.17.1/')).toBe( - 'http://localhost/ort-1.17.1/' - ); - }); - }); - - it('uses an application-supplied directory as given', () => { - navigateTo('/viewer/dicomweb'); + it('lets a caller-named directory outrank the configured one', () => { + utilities.setWasmBasePath('/assets/cs-wasm/'); expect(getOrtWasmPaths('https://cdn.example.com/onnx/1.17/')).toBe( 'https://cdn.example.com/onnx/1.17/' ); - expect(getOrtWasmPaths('/ort-1.17.1/')).toBe( - 'http://localhost/ort-1.17.1/' - ); - }); - - it('resolves a relative directory against the application base', () => { - process.env.PUBLIC_URL = '/pacs/'; - navigateTo('/pacs/viewer/dicomweb'); - - expect(getOrtWasmPaths('assets/ort/')).toBe( - 'http://localhost/pacs/assets/ort/' - ); + expect(getOrtWasmPaths('ort-1.17.1/')).toBe('http://localhost/ort-1.17.1/'); }); }); diff --git a/packages/ai/src/utils/getOrtWasmPaths.ts b/packages/ai/src/utils/getOrtWasmPaths.ts index a19b9d82ed..5167957d67 100644 --- a/packages/ai/src/utils/getOrtWasmPaths.ts +++ b/packages/ai/src/utils/getOrtWasmPaths.ts @@ -9,130 +9,40 @@ * user is currently on. `onnxruntime-web@1.17` cannot be addressed that way: * its `exports` map publishes only the JavaScript entry points, so * `new URL('onnxruntime-web/dist/ort-wasm-simd.jsep.wasm', import.meta.url)` - * fails to resolve. Applications copy `onnxruntime-web/dist` next to their - * bundle instead — the example runner copies it to `/ort` + * fails to resolve. Applications copy `onnxruntime-web/dist` somewhere they + * serve instead — the example runner copies it to `/ort` * (`utils/ExampleRunner/template-config.js`) — and point the runtime there. * - * Pointing at it with a bare `'ort/'` is the part that breaks. A - * document-relative prefix resolves against the current *route*, not against - * the application, so it only finds the copy when the page sits exactly one - * segment deep — which is why it works for the examples and for - * `viewer.ohif.org/segmentation`. A viewer served from `/viewer/dicomweb` - * requests `/viewer/ort/ort-wasm-*.wasm`, receives the SPA fallback's - * `index.html`, and ONNX dies with `expected magic word 00 61 73 6d, found - * 3c 21 64 6f` followed by "no available backend found". + * That makes it the same problem the codec binaries have, and it gets the same + * answer: `utilities.resolveWasmBasePath` in `@cornerstonejs/core`, which + * prefers the wasm directory the application declared and otherwise resolves + * the standard directory against the application's base. All this module adds + * is the standard directory name. * - * So resolve the prefix against where the *application* is served from, never - * against the current document URL: - * - * 1. when the system-level wasm directory is set, these binaries live there, - * exactly like the codec binaries do — an application that already serves - * its wasm out of one place (`init({ wasmBasePath })` on - * `@cornerstonejs/dicom-image-loader`) does not need a second setting; - * 2. otherwise `PUBLIC_URL`, the base an application declares for itself, - * defaulting to `'/'` when nothing declares one; - * 3. with the directory resolved against that base, anchored at the page's - * origin — the protocol and host of `window.location` and nothing more, - * which is how `dicom-microscopy-viewer` has always located its own assets - * from `PUBLIC_URL`. The route never takes part. + * When `onnxruntime-web` is eventually bumped to >= 1.21 its + * `*.bundle.min.mjs` builds resolve their own `.wasm` through `import.meta.url` + * and this module can be deleted. */ import { utilities } from '@cornerstonejs/core'; /** Directory applications copy `onnxruntime-web/dist` into. */ export const DEFAULT_ORT_WASM_DIRECTORY = 'ort/'; -/** - * Base assumed when nothing declares one. Applications are served from the root - * far more often than from a sub-path, and it is the same default every other - * reader of `PUBLIC_URL` picks. - */ -export const DEFAULT_PUBLIC_URL = '/'; - -/** - * Declared because `process` does not exist in a browser, and the bundlers that - * substitute `process.env.PUBLIC_URL` do it by matching that exact expression — - * so it has to be spelled out rather than reached through `globalThis`. - */ -declare const process: { env: Record }; - -/** Trailing slash, so URL resolution treats the value as a directory. */ -function asDirectory(path: string): string { - return path.endsWith('/') ? path : `${path}/`; -} - -/** - * `PUBLIC_URL` as a build-time substitution (Create React App and friends). - * - * A `typeof process !== 'undefined'` guard would be the obvious way to write - * this and does not work: bundlers rewrite `process.env.PUBLIC_URL` but leave - * the `typeof` check alone, and it is false in every browser bundle, so the - * substituted value would never be read. Catching the reference error is what - * is left. - */ -function getBuildTimePublicUrl(): string | undefined { - try { - return process.env.PUBLIC_URL || undefined; - } catch { - return undefined; - } -} - -/** - * The base the application declares for itself. `PUBLIC_URL` on the global is - * the runtime spelling `utils/demo/helpers/initDemo.ts` sets and - * `dicom-microscopy-viewer` reads; `config.path` is the same value carried in a - * viewer's configuration object. - */ -function getPublicUrl(): string { - const globals = globalThis as { - PUBLIC_URL?: string; - config?: { path?: string }; - }; - - return ( - globals.PUBLIC_URL || - globals.config?.path || - getBuildTimePublicUrl() || - DEFAULT_PUBLIC_URL - ); -} - -/** - * The page's origin, and nothing else. `PUBLIC_URL` is usually a path - * (`/pacs/`) and needs something absolute to resolve against, but the route is - * exactly what this module exists to keep out of the result — so hand over the - * protocol and host alone rather than `location.href` or `document.baseURI`. - */ -function getOrigin(): string | undefined { - const { location } = globalThis; - - return location ? `${location.protocol}//${location.host}` : undefined; -} - /** * Absolute URL prefix for the ONNX Runtime wasm binaries. * - * @param directory - directory holding `onnxruntime-web/dist`. Resolved against - * the application base, so `'assets/ort/'` is relative to the application, + * @param directory - directory holding `onnxruntime-web/dist`, overriding both + * the application's wasm directory and the default `ort/`. Resolved against + * the application's base, so `'assets/ort/'` is relative to the application, * `'/ort/'` to the server root, and a full URL is used as given — an * application serving the binaries from a CDN or a versioned path can say so. - * Passing this overrides the system-level wasm directory. Defaults to the - * system-level directory when one is set, otherwise to `ort/`. * @returns the prefix as an absolute URL, or the directory unchanged when there * is nothing to resolve it against (a non-browser context). */ export default function getOrtWasmPaths(directory?: string): string { - const prefix = asDirectory( - directory ?? utilities.getWasmBasePath() ?? DEFAULT_ORT_WASM_DIRECTORY - ); - const publicUrl = asDirectory(getPublicUrl()); - const origin = getOrigin(); - - try { - const base = origin ? new URL(publicUrl, origin) : new URL(publicUrl); - - return new URL(prefix, base).href; - } catch { - return prefix; - } + // A directory the caller names is the application talking, so it outranks the + // wasm directory the application configured. + return directory + ? utilities.resolveApplicationUrl(directory) + : utilities.resolveWasmBasePath(DEFAULT_ORT_WASM_DIRECTORY); } diff --git a/packages/core/src/utilities/index.ts b/packages/core/src/utilities/index.ts index f005ce6848..4de96fc15b 100644 --- a/packages/core/src/utilities/index.ts +++ b/packages/core/src/utilities/index.ts @@ -140,7 +140,12 @@ import { viewportIsInStackMode, } from './viewportCapabilities'; import { getNormalizedAspectRatio } from './getNormalizedAspectRatio'; -import { getWasmBasePath, setWasmBasePath } from './wasmBasePath'; +import { + getWasmBasePath, + resolveWasmBasePath, + setWasmBasePath, +} from './wasmBasePath'; +import resolveApplicationUrl, { getPublicUrl } from './resolveApplicationUrl'; export { updatePlaneRestriction } from './updatePlaneRestriction'; const getViewportModality = (viewport: IViewport, volumeId?: string) => _getViewportModality(viewport, volumeId, cache.getVolume); @@ -279,4 +284,7 @@ export { getNormalizedAspectRatio, getWasmBasePath, setWasmBasePath, + resolveWasmBasePath, + resolveApplicationUrl, + getPublicUrl, }; diff --git a/packages/core/src/utilities/resolveApplicationUrl.ts b/packages/core/src/utilities/resolveApplicationUrl.ts new file mode 100644 index 0000000000..72b59a553f --- /dev/null +++ b/packages/core/src/utilities/resolveApplicationUrl.ts @@ -0,0 +1,100 @@ +/** + * Resolves a path against the base the *application* is served from, rather + * than against the current document. + * + * A document-relative path resolves against the current route, so it only finds + * what it is looking for while the page happens to sit at the right depth. An + * application served from `/viewer/dicomweb` that asks for `assets/x` requests + * `/viewer/assets/x` and receives whatever the SPA fallback answers with, which + * for a binary asset means a corrupt file rather than an error. Anything an + * application stores beside its bundle and fetches by name — wasm binaries + * above all, since `WebAssembly.instantiateStreaming` reports the resulting + * `index.html` as a magic-word failure — has to be resolved against the + * application instead. + * + * `PUBLIC_URL` is where that base comes from: the value an application already + * injects to say where it is mounted, defaulting to the server root. + */ + +/** + * Declared because `process` does not exist in a browser, and the bundlers that + * substitute `process.env.PUBLIC_URL` do it by matching that exact expression — + * so it has to be spelled out rather than reached through `globalThis`. + */ +declare const process: { env: Record }; + +/** Base assumed when nothing declares one: applications sit at the root. */ +export const DEFAULT_PUBLIC_URL = '/'; + +/** + * `PUBLIC_URL` as a build-time substitution (Create React App and friends). + * + * A `typeof process !== 'undefined'` guard would be the obvious way to write + * this and does not work: bundlers rewrite `process.env.PUBLIC_URL` but leave + * the `typeof` check alone, and it is false in every browser bundle, so the + * substituted value would never be read. Catching the reference error is what + * is left. + */ +function getBuildTimePublicUrl(): string | undefined { + try { + return process.env.PUBLIC_URL || undefined; + } catch { + return undefined; + } +} + +/** + * The base the application declares for itself, defaulting to the server root. + * + * `PUBLIC_URL` on the global is the runtime spelling + * `utils/demo/helpers/initDemo.ts` sets and `dicom-microscopy-viewer` reads; + * `config.path` is the same value carried in a viewer's configuration object. + */ +export function getPublicUrl(): string { + const globals = globalThis as { + PUBLIC_URL?: string; + config?: { path?: string }; + }; + + return ( + globals.PUBLIC_URL || + globals.config?.path || + getBuildTimePublicUrl() || + DEFAULT_PUBLIC_URL + ); +} + +/** + * The page's origin, and nothing else. `PUBLIC_URL` is usually a path + * (`/pacs/`) and needs something absolute to resolve against, but the route is + * exactly what must stay out of the result — so hand over the protocol and host + * alone rather than `location.href` or `document.baseURI`. + */ +function getOrigin(): string | undefined { + const { location } = globalThis; + + return location ? `${location.protocol}//${location.host}` : undefined; +} + +/** + * Resolves a path against the application's base. + * + * @param path - a relative path is relative to the application, an absolute + * path is relative to the server root, and a full URL (a CDN) is used as + * given. An empty path yields the application's base itself. + * @returns an absolute URL, or `path` unchanged when there is nothing to + * resolve it against (a non-browser context). + */ +export default function resolveApplicationUrl(path = ''): string { + const publicUrl = getPublicUrl(); + // `PUBLIC_URL=/pacs` is a common spelling, and URL resolution would treat + // that last segment as a file name and discard it. + const base = publicUrl.endsWith('/') ? publicUrl : `${publicUrl}/`; + const origin = getOrigin(); + + try { + return new URL(path, origin ? new URL(base, origin) : new URL(base)).href; + } catch { + return path; + } +} diff --git a/packages/core/src/utilities/wasmBasePath.ts b/packages/core/src/utilities/wasmBasePath.ts index 27a38bc796..b8724ddb31 100644 --- a/packages/core/src/utilities/wasmBasePath.ts +++ b/packages/core/src/utilities/wasmBasePath.ts @@ -1,20 +1,27 @@ /** - * System-level location of the WebAssembly binaries Cornerstone loads at - * runtime. + * Where Cornerstone loads its WebAssembly binaries from. * - * The codec binaries the DICOM image loader decodes with are the reason this - * exists. Each decoder resolves its binary against a bare - * `@cornerstonejs/codec-...` specifier, which bundlers do not rewrite, so a - * bundled application copies the binaries somewhere it serves and names that - * directory once — `init({ wasmBasePath })` on the loader, see - * `LoaderOptions.wasmBasePath`. That option is recorded here so it is not - * private to the loader: any package that has to locate a wasm binary honours - * the same directory, which is how `@cornerstonejs/ai` finds the ONNX Runtime - * binaries (see `getOrtWasmPaths`). + * A wasm binary cannot be located the way a bundled asset is. The usual + * `new URL(, import.meta.url)` only works when the bundler resolves, + * emits and hashes the file, and several of the binaries Cornerstone needs + * cannot be reached that way: the decoders name their codecs with bare + * `@cornerstonejs/codec-...` specifiers, which bundlers do not rewrite inside + * `new URL(...)`, and `onnxruntime-web@1.17` publishes only JavaScript entry + * points in its `exports` map. An application therefore copies those binaries + * somewhere it serves and *declares* where they are. * - * It is deliberately one directory for every binary rather than a path per - * consumer — applications serve them all out of a single place. + * Declaring it once is the point of `wasmBasePath`: one directory holding every + * binary, set through `init({ wasmBasePath })` on + * `@cornerstonejs/dicom-image-loader` (see `LoaderOptions.wasmBasePath`) and + * recorded here rather than privately in the loader, so that every package + * locating a binary honours the same directory. + * + * When nothing declares one, each set of binaries falls back to the standard + * directory its owner copies them into, resolved against the application rather + * than against the current document — see `resolveApplicationUrl` for why that + * distinction is the whole point. */ +import resolveApplicationUrl from './resolveApplicationUrl'; /** Directory every wasm binary is loaded from, or undefined for the default. */ let wasmBasePath: string | undefined; @@ -34,3 +41,26 @@ export function setWasmBasePath(basePath?: string): void { export function getWasmBasePath(): string | undefined { return wasmBasePath; } + +/** + * Absolute URL of the directory a set of wasm binaries loads from. + * + * @param defaultDirectory - directory to use when nothing has declared one: the + * standard location its owner copies the binaries into, e.g. `ort/` for the + * ONNX Runtime. Both it and a configured `wasmBasePath` are resolved the same + * way — relative to the application, or to the server root when absolute, or + * used as given when a full URL. + * @returns the directory as an absolute URL with a trailing slash, or the + * unresolved directory when there is nothing to resolve it against (a + * non-browser context). + */ +export function resolveWasmBasePath(defaultDirectory = ''): string { + const directory = wasmBasePath || defaultDirectory; + + // A trailing slash, so the value resolves as a directory rather than having + // its last segment discarded as a file name. An empty directory stays empty: + // it resolves to the application's base itself, not to the server root. + return resolveApplicationUrl( + !directory || directory.endsWith('/') ? directory : `${directory}/` + ); +} diff --git a/packages/core/test/wasmBasePath.jest.js b/packages/core/test/wasmBasePath.jest.js new file mode 100644 index 0000000000..e54fc755de --- /dev/null +++ b/packages/core/test/wasmBasePath.jest.js @@ -0,0 +1,195 @@ +import { + getWasmBasePath, + resolveWasmBasePath, + setWasmBasePath, +} from '../src/utilities/wasmBasePath'; +import resolveApplicationUrl, { + getPublicUrl, +} from '../src/utilities/resolveApplicationUrl'; + +/** Puts the document on `route` the way a client-side router would. */ +function navigateTo(route) { + window.history.replaceState(null, '', route); +} + +describe('wasm base path', () => { + const publicUrl = process.env.PUBLIC_URL; + + beforeEach(() => { + delete globalThis.PUBLIC_URL; + delete globalThis.config; + delete process.env.PUBLIC_URL; + setWasmBasePath(undefined); + navigateTo('/'); + }); + + afterAll(() => { + if (publicUrl === undefined) { + delete process.env.PUBLIC_URL; + } else { + process.env.PUBLIC_URL = publicUrl; + } + }); + + describe('getPublicUrl', () => { + it('defaults to the server root', () => { + expect(getPublicUrl()).toBe('/'); + }); + + it('reads the runtime global', () => { + globalThis.PUBLIC_URL = '/pacs/'; + + expect(getPublicUrl()).toBe('/pacs/'); + }); + + it('reads a viewer configuration object', () => { + globalThis.config = { path: '/pacs/' }; + + expect(getPublicUrl()).toBe('/pacs/'); + }); + + it('reads the build-time substitution', () => { + process.env.PUBLIC_URL = '/pacs/'; + + expect(getPublicUrl()).toBe('/pacs/'); + }); + + it('prefers the runtime value over the build-time one', () => { + globalThis.PUBLIC_URL = '/runtime/'; + process.env.PUBLIC_URL = '/build/'; + + expect(getPublicUrl()).toBe('/runtime/'); + }); + }); + + describe('resolveApplicationUrl', () => { + it('resolves against the server root when nothing declares a base', () => { + expect(resolveApplicationUrl('assets/x.wasm')).toBe( + 'http://localhost/assets/x.wasm' + ); + }); + + it('ignores the route', () => { + // The bug this guards: a document-relative path resolves against the + // route, so a viewer on /viewer/dicomweb fetched /viewer/assets/ and got + // index.html back. + navigateTo('/viewer/dicomweb/studies/1.2.3'); + + expect(resolveApplicationUrl('assets/x.wasm')).toBe( + 'http://localhost/assets/x.wasm' + ); + }); + + it('resolves against a sub-path base', () => { + process.env.PUBLIC_URL = '/pacs/'; + navigateTo('/pacs/viewer/dicomweb'); + + expect(resolveApplicationUrl('assets/x.wasm')).toBe( + 'http://localhost/pacs/assets/x.wasm' + ); + }); + + it('tolerates a base without its trailing slash', () => { + process.env.PUBLIC_URL = '/pacs'; + + expect(resolveApplicationUrl('assets/x.wasm')).toBe( + 'http://localhost/pacs/assets/x.wasm' + ); + }); + + it('keeps an absolute path at the server root', () => { + process.env.PUBLIC_URL = '/pacs/'; + + expect(resolveApplicationUrl('/assets/x.wasm')).toBe( + 'http://localhost/assets/x.wasm' + ); + }); + + it('uses a full URL as given', () => { + expect(resolveApplicationUrl('https://cdn.example.com/x.wasm')).toBe( + 'https://cdn.example.com/x.wasm' + ); + }); + + it('resolves a full URL base as given', () => { + globalThis.PUBLIC_URL = 'http://cdn.example.com/app/'; + + expect(resolveApplicationUrl('assets/x.wasm')).toBe( + 'http://cdn.example.com/app/assets/x.wasm' + ); + }); + + it('yields the base itself for an empty path', () => { + process.env.PUBLIC_URL = '/pacs/'; + + expect(resolveApplicationUrl()).toBe('http://localhost/pacs/'); + }); + }); + + describe('resolveWasmBasePath', () => { + it('resolves the default directory against the application', () => { + navigateTo('/viewer/dicomweb'); + + expect(resolveWasmBasePath('ort/')).toBe('http://localhost/ort/'); + }); + + it('resolves the default directory against a sub-path base', () => { + process.env.PUBLIC_URL = '/pacs/'; + navigateTo('/pacs/viewer/dicomweb'); + + expect(resolveWasmBasePath('ort/')).toBe('http://localhost/pacs/ort/'); + }); + + describe('with a configured directory', () => { + it('takes it in preference to the default one', () => { + setWasmBasePath('/assets/cs-wasm/'); + process.env.PUBLIC_URL = '/pacs/'; + navigateTo('/pacs/viewer/dicomweb'); + + expect(resolveWasmBasePath('ort/')).toBe( + 'http://localhost/assets/cs-wasm/' + ); + }); + + it('uses a full URL as given', () => { + setWasmBasePath('https://cdn.example.com/wasm/'); + + expect(resolveWasmBasePath('ort/')).toBe( + 'https://cdn.example.com/wasm/' + ); + }); + + it('adds the trailing slash it may be missing', () => { + setWasmBasePath('/assets/cs-wasm'); + + expect(resolveWasmBasePath('ort/')).toBe( + 'http://localhost/assets/cs-wasm/' + ); + }); + + it('resolves a relative one against the application', () => { + setWasmBasePath('cs-wasm/'); + process.env.PUBLIC_URL = '/pacs/'; + navigateTo('/pacs/viewer/dicomweb'); + + expect(resolveWasmBasePath('ort/')).toBe( + 'http://localhost/pacs/cs-wasm/' + ); + }); + + it('reports it unresolved through getWasmBasePath', () => { + setWasmBasePath('/assets/cs-wasm/'); + + expect(getWasmBasePath()).toBe('/assets/cs-wasm/'); + }); + + it('is cleared by an empty value', () => { + setWasmBasePath('/assets/cs-wasm/'); + setWasmBasePath(''); + + expect(getWasmBasePath()).toBeUndefined(); + expect(resolveWasmBasePath('ort/')).toBe('http://localhost/ort/'); + }); + }); + }); +}); From 2e357a3703d0bff7cefc048f71d01667b982b21a Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Mon, 17 Aug 2026 15:09:54 -0400 Subject: [PATCH 6/6] refactor(ai): drop the ort wasm directory override The configured `wasmBasePath` and `PUBLIC_URL` are the whole story, so `getOrtWasmPaths` is now one standard call with nothing to parameterise: return utilities.resolveWasmBasePath(DEFAULT_ORT_WASM_DIRECTORY); The override it accepted was a third way to name the location, reachable by nobody - the function has never shipped, and an application that wants the binaries elsewhere already sets `wasmBasePath`, declares `PUBLIC_URL`, or assigns `ort.env.wasm.wasmPaths` itself, which the controller leaves alone. Also says out loud in the module and the docs why a subpath deployment has to declare one of the two rather than falling back to something: with only JavaScript entry points in the `onnxruntime-web@1.17` exports map, there is no module-relative base to derive. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ai/src/index.ts | 9 ++++---- packages/ai/src/utils/getOrtWasmPaths.test.ts | 17 ++++++-------- packages/ai/src/utils/getOrtWasmPaths.ts | 23 +++++++++---------- .../getting-started/vue-angular-react-vite.md | 4 +++- 4 files changed, 25 insertions(+), 28 deletions(-) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 5e581ceb86..61097f1c3f 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -11,11 +11,10 @@ export { LabelmapSlicePropagationTool, MarkerLabelmapTool, Events, - // Exported so an application can put the ONNX Runtime binaries somewhere - // this cannot work out for itself - a CDN, a versioned path - by assigning - // `ort.env.wasm.wasmPaths` before the controller runs. Serving them from the - // same directory as the codec binaries needs nothing from here: set that - // directory once, with `init({ wasmBasePath })` on the DICOM image loader. + // Exported to answer where the ONNX Runtime binaries will be looked for, not + // to configure it: the location comes from `init({ wasmBasePath })` on the + // DICOM image loader, from `PUBLIC_URL`, or from assigning + // `ort.env.wasm.wasmPaths` directly, which the controller leaves alone. getOrtWasmPaths, DEFAULT_ORT_WASM_DIRECTORY, }; diff --git a/packages/ai/src/utils/getOrtWasmPaths.test.ts b/packages/ai/src/utils/getOrtWasmPaths.test.ts index cc5f3658c7..ba0d0420f5 100644 --- a/packages/ai/src/utils/getOrtWasmPaths.test.ts +++ b/packages/ai/src/utils/getOrtWasmPaths.test.ts @@ -2,10 +2,10 @@ import { utilities } from '@cornerstonejs/core'; import getOrtWasmPaths from './getOrtWasmPaths'; /** - * How a base is resolved is `resolveApplicationUrl`/`resolveWasmBasePath` in - * `@cornerstonejs/core`, covered by `packages/core/test/wasmBasePath.jest.js`. - * What is left here is what this module decides: the standard directory, and - * which of the two resolvers a given call goes through. + * How a base is resolved is `resolveWasmBasePath` in `@cornerstonejs/core`, + * covered by `packages/core/test/wasmBasePath.jest.js`. What is left here is the + * one thing this module decides - the standard directory - and that the two ways + * an application declares a location both reach the ONNX Runtime. */ describe('getOrtWasmPaths', () => { const publicUrl = process.env.PUBLIC_URL; @@ -41,12 +41,9 @@ describe('getOrtWasmPaths', () => { expect(getOrtWasmPaths()).toBe('http://localhost/assets/cs-wasm/'); }); - it('lets a caller-named directory outrank the configured one', () => { - utilities.setWasmBasePath('/assets/cs-wasm/'); + it('serves the binaries from a CDN the configured directory names', () => { + utilities.setWasmBasePath('https://cdn.example.com/wasm/'); - expect(getOrtWasmPaths('https://cdn.example.com/onnx/1.17/')).toBe( - 'https://cdn.example.com/onnx/1.17/' - ); - expect(getOrtWasmPaths('ort-1.17.1/')).toBe('http://localhost/ort-1.17.1/'); + expect(getOrtWasmPaths()).toBe('https://cdn.example.com/wasm/'); }); }); diff --git a/packages/ai/src/utils/getOrtWasmPaths.ts b/packages/ai/src/utils/getOrtWasmPaths.ts index 5167957d67..ff3a48a0a0 100644 --- a/packages/ai/src/utils/getOrtWasmPaths.ts +++ b/packages/ai/src/utils/getOrtWasmPaths.ts @@ -19,6 +19,13 @@ * the standard directory against the application's base. All this module adds * is the standard directory name. * + * Those two are the whole story, and there is deliberately no third way to name + * the location here. Since the binaries cannot be reached from this module, + * there is nothing to fall back to: an application serving them from somewhere + * else says so with `init({ wasmBasePath })`, or declares where it is mounted + * with `PUBLIC_URL`, or assigns `ort.env.wasm.wasmPaths` itself — which the + * controller leaves alone. + * * When `onnxruntime-web` is eventually bumped to >= 1.21 its * `*.bundle.min.mjs` builds resolve their own `.wasm` through `import.meta.url` * and this module can be deleted. @@ -29,20 +36,12 @@ import { utilities } from '@cornerstonejs/core'; export const DEFAULT_ORT_WASM_DIRECTORY = 'ort/'; /** - * Absolute URL prefix for the ONNX Runtime wasm binaries. + * Absolute URL prefix for the ONNX Runtime wasm binaries: the wasm directory the + * application configured, or `ort/` under the application's base. * - * @param directory - directory holding `onnxruntime-web/dist`, overriding both - * the application's wasm directory and the default `ort/`. Resolved against - * the application's base, so `'assets/ort/'` is relative to the application, - * `'/ort/'` to the server root, and a full URL is used as given — an - * application serving the binaries from a CDN or a versioned path can say so. * @returns the prefix as an absolute URL, or the directory unchanged when there * is nothing to resolve it against (a non-browser context). */ -export default function getOrtWasmPaths(directory?: string): string { - // A directory the caller names is the application talking, so it outranks the - // wasm directory the application configured. - return directory - ? utilities.resolveApplicationUrl(directory) - : utilities.resolveWasmBasePath(DEFAULT_ORT_WASM_DIRECTORY); +export default function getOrtWasmPaths(): string { + return utilities.resolveWasmBasePath(DEFAULT_ORT_WASM_DIRECTORY); } diff --git a/packages/docs/docs/getting-started/vue-angular-react-vite.md b/packages/docs/docs/getting-started/vue-angular-react-vite.md index e1e48aa005..8b3ca3f2a3 100644 --- a/packages/docs/docs/getting-started/vue-angular-react-vite.md +++ b/packages/docs/docs/getting-started/vue-angular-react-vite.md @@ -151,7 +151,9 @@ dicomImageLoaderInit({ A relative `wasmBasePath` resolves against the decode worker's location, and an absolute path or full URL (e.g. a CDN) is used as given. When the option is unset, the default `import.meta.url` resolution applies, which is what unbundled and script-tag usage relies on. -The path is system-wide rather than loader-specific, so it is also where `@cornerstonejs/ai` looks for the ONNX Runtime binaries — copy `onnxruntime-web/dist` into the same directory and there is nothing further to configure. With no `wasmBasePath` set, those binaries are expected in `ort/` under the application's base, which is taken from `PUBLIC_URL` (`window.PUBLIC_URL`, `window.config.path` or the build-time `process.env.PUBLIC_URL`) and defaults to the server root. A subpath deployment that does not set `wasmBasePath` should therefore declare `PUBLIC_URL`; either way the location no longer depends on the route the user happens to be on. +The path is system-wide rather than loader-specific, so it is also where `@cornerstonejs/ai` looks for the ONNX Runtime binaries — copy `onnxruntime-web/dist` into the same directory and there is nothing further to configure. With no `wasmBasePath` set, those binaries are expected in `ort/` under the application's base, which is taken from `PUBLIC_URL` (`window.PUBLIC_URL`, `window.config.path` or the build-time `process.env.PUBLIC_URL`) and defaults to the server root. + +A **subpath** deployment therefore has to declare one of the two. `onnxruntime-web@1.17` exports only its JavaScript entry points, so its binaries cannot be located relative to the module that loads them the way the codecs' can — there is no base to derive and nothing to fall back to. Set `wasmBasePath`, or set `PUBLIC_URL` to where the application is mounted. Either way the location stops depending on the route the user happens to be on. ---