# Integrating openslide-js
openslide-js runs its WASM in a pool of Web Workers, and the WASM is compiled with pthreads
(`USE_PTHREADS=1`). This guide covers how to install it and load the worker + WASM in every
environment: plain ESM, webpack/Next.js, and Vite.
> **Required headers (all setups).** The WASM uses `SharedArrayBuffer`, so the page serving your
> app must be cross-origin isolated:
> ```
> Cross-Origin-Opener-Policy: same-origin
> Cross-Origin-Embedder-Policy: require-corp
> ```
> Check `crossOriginIsolated === true` in the browser console. If it's `false`, workers cannot boot.
## TL;DR
- **No bundler (plain ESM):** `await OpenSlide.initialize()` — zero config.
- **Any bundler (webpack/Next.js, Vite, Rollup):** wire the worker + WASM **explicitly** with the
subpath exports and `initialize()` options (recipe below), **or** use the
[`/single` variant](#single-file-variant) for the least config.
- **Helpers:** `@.../next` (`withOpenSlide`) injects the `.wasm` rule; `@.../vite` (`openslide()`)
sets dev/preview headers.
---
## Installing
```bash
npm install @computationalpathologygroup/openslide-js
```
All imports use the package name `@computationalpathologygroup/openslide-js`.
---
## Why bundlers need explicit wiring
The package deliberately resolves its default worker and WASM glue through **non-literal** paths:
```ts
const workerPath = './worker.js';
new Worker(new URL(workerPath, import.meta.url), { type: 'module' }); // not a string literal
```
Webpack 5 and Vite only auto-emit/trace a worker or asset when the argument is a **string
literal**. A literal `new Worker(new URL('./worker.js', import.meta.url))` makes the bundler treat
the worker as an *entry* and statically trace its imports — which pulls in the Emscripten glue and
its `em-pthread` chunk, producing a circular dependency with the framework runtime (under Next.js
this crashes with `Cannot read properties of undefined (reading 'call')`). Using a variable
bypasses that tracing entirely; native ESM still resolves it at runtime, and bundler consumers
provide the assets themselves as plain, untraced files.
## Plain ESM / no bundler
Serve the package files as-is and import the ESM entry. The worker and glue resolve relative to
each other at runtime — nothing to copy:
```ts
import { OpenSlide } from '@computationalpathologygroup/openslide-js';
const openslide = await OpenSlide.initialize();
```
Just send the COOP/COEP headers above.
## webpack 5 / Next.js / Vite — explicit recipe
Reference the package's subpath exports with your own **literal** `new URL(..., import.meta.url)`.
Each one emits exactly one untraced asset (the worker JS, the glue JS, the `.wasm` binary). Then
pass those URLs into `initialize()` as variables, so nothing gets force-traced:
```ts
import { OpenSlide } from '@computationalpathologygroup/openslide-js';
const workerUrl = new URL('@computationalpathologygroup/openslide-js/worker', import.meta.url);
const wasmJsUrl = new URL('@computationalpathologygroup/openslide-js/wasm/openslide.js', import.meta.url).href;
const wasmBinaryUrl = new URL('@computationalpathologygroup/openslide-js/wasm/openslide.wasm', import.meta.url);
const openslide = await OpenSlide.initialize({
// workerCount defaults to navigator.hardwareConcurrency. Set it explicitly if you want.
workerCount: 4,
// Variable URL → bundler does not trace the worker's imports.
workerFactory: () => new Worker(workerUrl, { type: 'module' }),
// Makes the worker load the glue from this URL instead of the package-relative default.
wasmUrl: wasmJsUrl,
// Hands the worker the WASM bytes, so Emscripten skips its own openslide.wasm
// fetch — removes any sibling-file layout requirement.
wasmBinary: await (await fetch(wasmBinaryUrl)).arrayBuffer(),
});
```
> **Relative URLs just work.** `wasmUrl` is resolved against `document.baseURI` *inside*
> `initialize()` before it is sent to the worker. Bundlers that emit relative asset URLs
> (Next.js `assetPrefix: '.'`, some Vite/Rollup configs) therefore need no special handling — a
> relative `wasmUrl` is absolutised on the main thread, so it can't mis-resolve against the
> worker's own base URL.
> **Worker boot is self-contained.** The published `worker.js` is bundled (it inlines its own
> helpers), so the asset-module pattern `new URL('@.../worker', import.meta.url)` — which copies
> the worker verbatim without bundling its imports — no longer dies on a missing sibling file. If
> a worker still fails to boot, the rejected `OpenSlideError` reports the filename/line and the
> `crossOriginIsolated` state (a missing COOP/COEP setup is the usual cause) instead of
> `Worker error: undefined`.
> **Multiple workers.** `workerCount` is unchanged: the pool calls your `workerFactory` once per
> slot, each `new Worker(...)` loading the same self-contained `worker.js`. Each worker holds an
> independent WASM instance.
> **One extra worker: the I/O broker.** Unless `io: { enabled: false }` is passed, `initialize()`
> calls your `workerFactory` **one additional time** to spawn the shared I/O broker — the same
> `worker.js`, switched into broker mode by its first message. It never loads the WASM (no
> `wasmBinary` is sent to it); it owns the block cache shared by all decode workers and performs
> all local-file and HTTP-range reads asynchronously. No wiring changes are needed.
## I/O tuning
The shared I/O layer is on by default and needs no configuration. Knobs, with defaults:
```ts
const openslide = await OpenSlide.initialize({
// ...worker/wasm wiring as above...
io: {
enabled: true, // false → legacy per-worker I/O (createLazyFile/WORKERFS)
blockSize: 1024 * 1024, // bytes per cached block / per HTTP range request
brokerCacheBytes: 256 * 1024 * 1024, // shared LRU cache budget
readAhead: 2, // blocks prefetched ahead of sequential reads
maxConcurrentReads: 4, // concurrent readRegion calls per decode worker
},
});
```
`Slide.readRegion()` and `DeepZoomGenerator.getTile()` also accept a trailing
`{ signal?: AbortSignal }`: aborting cancels the read while it is still queued in the worker
(rejecting with `OpenSlideAbortError`, `name === 'AbortError'`); a read that already entered the
WASM runs to completion. Viewers should abort tiles that scroll out of view during pan/zoom.
## Next.js
### Config — `.wasm` rule + required headers
webpack does not treat `.wasm` as an asset by default. Wrap your config in `withOpenSlide` to inject
the rule (it composes with any `webpack` function you already have) and add the COOP/COEP headers:
```js
// next.config.js
const { withOpenSlide } = require('@computationalpathologygroup/openslide-js/next');
module.exports = withOpenSlide({
reactStrictMode: true,
async headers() {
return [{
source: '/:path*',
headers: [
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
{ key: 'Cross-Origin-Embedder-Policy', value: 'require-corp' },
],
}];
},
});
```
ESM config (`next.config.mjs`) is the same with
`import { withOpenSlide } from '@computationalpathologygroup/openslide-js/next'`.
Equivalent manual rule (if you'd rather not use the helper)
```js
module.exports = {
webpack: (config) => {
config.module.rules.push({ test: /\.wasm$/, type: 'asset/resource' });
return config;
},
// ...same headers() as above
};
```
> **Use the webpack build, not Turbopack.** The `new URL(..., import.meta.url)` asset emission and
> the `.wasm` rule are webpack features — run `next dev` / `next build` without `--turbo` (or test
> Turbopack separately). Using the [`/single` variant](#single-file-variant)? You don't need the
> `.wasm` rule at all.
### A client component
WASM + workers are browser-only — keep this in a `'use client'` component, initialize inside an
effect, and terminate on unmount:
```tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import { OpenSlide } from '@computationalpathologygroup/openslide-js';
export default function SlideViewer() {
const osRef = useRef(null);
const [version, setVersion] = useState('');
useEffect(() => {
let disposed = false;
(async () => {
const workerUrl = new URL('@computationalpathologygroup/openslide-js/worker', import.meta.url);
const wasmJsUrl = new URL('@computationalpathologygroup/openslide-js/wasm/openslide.js', import.meta.url).href;
const wasmBinaryUrl = new URL('@computationalpathologygroup/openslide-js/wasm/openslide.wasm', import.meta.url);
const os = await OpenSlide.initialize({
workerCount: 4,
workerFactory: () => new Worker(workerUrl, { type: 'module' }),
wasmUrl: wasmJsUrl,
wasmBinary: await (await fetch(wasmBinaryUrl)).arrayBuffer(),
});
if (disposed) { os.terminate(); return; }
osRef.current = os;
setVersion(await os.getVersion());
})().catch((err) => {
// Worker boot failures report filename/line and crossOriginIsolated=...
console.error('OpenSlide init failed:', err);
});
return () => {
disposed = true;
osRef.current?.terminate();
osRef.current = null;
};
}, []);
async function onFile(e: React.ChangeEvent) {
const file = e.target.files?.[0];
if (!file || !osRef.current) return;
const slide = await osRef.current.open(file);
console.log('levels:', slide.levelCount, slide.levelDimensions);
// const region = await slide.readRegion(x, y, level, width, height); // returns ImageData (RGBA)
await slide.close();
}
return (
OpenSlide version: {version || 'initializing…'}
);
}
```
Render it with SSR disabled so it only runs in the browser:
```tsx
// app/page.tsx
import dynamic from 'next/dynamic';
const SlideViewer = dynamic(() => import('./SlideViewer'), { ssr: false });
export default function Page() {
return ;
}
```
> **Static export (`output: 'export'`):** Next's `headers()` does **not** apply to statically
> exported files. Set COOP/COEP at your static host instead (e.g. a `_headers` file on
> Netlify/Cloudflare Pages, or your nginx/CDN config).
## Vite
Vite resolves the same `new URL('', import.meta.url)` form and emits the assets. Append
`?url` if your config needs an explicit asset URL (e.g. `'@.../wasm/openslide.wasm?url'`). The main
thing to wire is the dev/preview COOP/COEP headers — the bundled plugin sets them for you:
```ts
// vite.config.ts
import { openslide } from '@computationalpathologygroup/openslide-js/vite';
export default {
plugins: [openslide()],
};
```
Equivalent manual headers
```ts
export default {
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
};
```
> The plugin only sets the dev/preview headers — your production host must send the same headers.
## Single-file variant
The `/single` export is built with Emscripten `SINGLE_FILE`, inlining the `.wasm` as base64 into the
glue JS. This removes the separate `.wasm` asset entirely, so consumers need **no `.wasm` bundler
rule and no `wasmBinary` plumbing** — just the worker and the COOP/COEP headers:
```ts
import { OpenSlide } from '@computationalpathologygroup/openslide-js/single';
const openslide = await OpenSlide.initialize({
workerFactory: () =>
new Worker(new URL('@computationalpathologygroup/openslide-js/single/worker', import.meta.url), { type: 'module' }),
// no wasmUrl, no wasmBinary
});
```
The single-file worker loads its inlined glue as a sibling asset, so the only files emitted are the
worker and the glue (two assets, versus the standard variant's worker + glue + `.wasm`).
**Trade-off:** the glue is ~33% larger and cold start is slower (no streaming compilation, since the
binary is base64 in JS rather than a standalone `.wasm`). Prefer the standard variant when bundle
size and startup latency matter; prefer `/single` for the simplest possible bundler integration.
> COOP/COEP headers are still required — `SharedArrayBuffer`/pthreads are unchanged.
## Subpath exports
| Export | Points to |
|---|---|
| `@computationalpathologygroup/openslide-js` | main ESM/CJS entry |
| `@computationalpathologygroup/openslide-js/worker` | the worker JS (self-contained bundle) |
| `@computationalpathologygroup/openslide-js/wasm/openslide.js` | the Emscripten glue |
| `@computationalpathologygroup/openslide-js/wasm/openslide.wasm` | the WASM binary |
| `@computationalpathologygroup/openslide-js/single` | single-file variant entry (WASM inlined) |
| `@computationalpathologygroup/openslide-js/single/worker` | single-file variant worker |
| `@computationalpathologygroup/openslide-js/next` | `withOpenSlide(nextConfig)` helper |
| `@computationalpathologygroup/openslide-js/vite` | `openslide()` Vite plugin |
| `@computationalpathologygroup/openslide-js/package.json` | package manifest |
## pthreads note
The glue spawns internal pthread workers from its own `import.meta.url` at runtime. Serving the glue
via the `./wasm/openslide.js` export (an emitted asset with a stable URL) lets those resolve, and
`wasmBinary` avoids the separate `.wasm` fetch. If you still see pthread-worker load errors,
double-check the COOP/COEP headers — `SharedArrayBuffer` is required for pthreads.
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| `OpenSlideError: Worker error: … crossOriginIsolated=false` | COOP/COEP headers not applied. Verify `crossOriginIsolated` in the console; for Next.js static export set headers at the host. |
| `Worker error: … module-load failure (check the network tab)` | An asset 404'd. Confirm the `new URL(...)` imports resolve (Network tab) and you're on the webpack build, not Turbopack. |
| `.wasm` request 404 / "module parse failed" | The `.wasm` asset rule is missing — wrap `next.config.js` in `withOpenSlide(...)`, or switch to `/single`. |
| Next.js crash `Cannot read properties of undefined (reading 'call')` | A literal `new Worker(new URL('…'))` force-traced the pthread glue. Use `workerFactory` with a **variable** URL as shown above. |