diff --git a/CHANGELOG.md b/CHANGELOG.md
index a18b2cf4fc7b76a809f28936971231908bd3d26b..8e4c300d0c266d0d090ac28dec6067afaaa0da50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ Note that since we don't clearly distinguish between a public and private interf
 
 ## [Unreleased]
 
+- Add `HeadlessPluginContext` and `Canvas3DRenderer` to be used in Node.js
+- Add example `image-renderer`
 - Fix wrong offset when rendering text with orthographic projection
 - Update camera/handle helper when `devicePixelRatio` changes
 
diff --git a/package-lock.json b/package-lock.json
index e16194531cfe8fa78ec55ce243c92a7fb23e2aa5..ca63ded9f477bb6af29233555c2df675df5662e4 100644
Binary files a/package-lock.json and b/package-lock.json differ
diff --git a/package.json b/package.json
index 61b5e45954a498f0b7b4a780754b477c9577d5de..5bc2612c0a46f2e4642eb429253359f185322128 100644
--- a/package.json
+++ b/package.json
@@ -107,6 +107,8 @@
     "@graphql-codegen/typescript-operations": "^2.5.12",
     "@types/cors": "^2.8.13",
     "@types/gl": "^6.0.2",
+    "@types/jpeg-js": "^0.3.7",
+    "@types/pngjs": "^6.0.1",
     "@types/jest": "^29.4.0",
     "@types/react": "^18.0.27",
     "@types/react-dom": "^18.0.10",
@@ -165,5 +167,10 @@
   "peerDependencies": {
     "react": "^18.1.0 || ^17.0.2 || ^16.14.0",
     "react-dom": "^18.1.0 || ^17.0.2 || ^16.14.0"
+  },
+  "optionalDependencies": {
+    "gl": "^6.0.2",
+    "jpeg-js": "^0.4.4",
+    "pngjs": "^6.0.0"
   }
 }
diff --git a/src/examples/image-renderer/index.ts b/src/examples/image-renderer/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ceed7ccc301e2a53a795901f7e79f1f1842ff6c3
--- /dev/null
+++ b/src/examples/image-renderer/index.ts
@@ -0,0 +1,87 @@
+/**
+ * Copyright (c) 2021-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ *
+ * Example command-line application generating images of PDB structures
+ * Build: npm install --no-save gl jpeg-js pngjs  // these packages are not listed in dependencies for performance reasons
+ *        npm run build
+ * Run:   node lib/commonjs/examples/image-renderer 1cbs ../outputs_1cbs/
+ */
+
+import { ArgumentParser } from 'argparse';
+import * as fs from 'fs';
+import path from 'path';
+
+import { STYLIZED_POSTPROCESSING } from '../../mol-canvas3d/renderer';
+import { Download, ParseCif } from '../../mol-plugin-state/transforms/data';
+import { ModelFromTrajectory, StructureComponent, StructureFromModel, TrajectoryFromMmCif } from '../../mol-plugin-state/transforms/model';
+import { StructureRepresentation3D } from '../../mol-plugin-state/transforms/representation';
+import { HeadlessPluginContext } from '../../mol-plugin/headless-plugin-context';
+import { DefaultPluginSpec } from '../../mol-plugin/spec';
+
+
+interface Args {
+    pdbId: string,
+    outDirectory: string
+}
+
+function parseArguments(): Args {
+    const parser = new ArgumentParser({ description: 'Example command-line application generating images of PDB structures' });
+    parser.add_argument('pdbId', { help: 'PDB identifier' });
+    parser.add_argument('outDirectory', { help: 'Directory for outputs' });
+    const args = parser.parse_args();
+    return { ...args };
+}
+
+async function main() {
+    const args = parseArguments();
+    const url = `https://www.ebi.ac.uk/pdbe/entry-files/download/${args.pdbId}.bcif`;
+    console.log('PDB ID:', args.pdbId);
+    console.log('Source URL:', url);
+    console.log('Outputs:', args.outDirectory);
+
+    // Create a headless plugin
+    const plugin = new HeadlessPluginContext(DefaultPluginSpec(), { width: 800, height: 800 });
+    await plugin.init();
+
+    // Download and visualize data in the plugin
+    const update = plugin.build();
+    const structure = update.toRoot()
+        .apply(Download, { url, isBinary: true })
+        .apply(ParseCif)
+        .apply(TrajectoryFromMmCif)
+        .apply(ModelFromTrajectory)
+        .apply(StructureFromModel);
+    const polymer = structure.apply(StructureComponent, { type: { name: 'static', params: 'polymer' } });
+    const ligand = structure.apply(StructureComponent, { type: { name: 'static', params: 'ligand' } });
+    polymer.apply(StructureRepresentation3D, {
+        type: { name: 'cartoon', params: { alpha: 1 } },
+        colorTheme: { name: 'sequence-id', params: {} },
+    });
+    ligand.apply(StructureRepresentation3D, {
+        type: { name: 'ball-and-stick', params: { sizeFactor: 1 } },
+        colorTheme: { name: 'element-symbol', params: { carbonColor: { name: 'element-symbol', params: {} } } },
+        sizeTheme: { name: 'physical', params: {} },
+    });
+    await update.commit();
+
+    // Export images
+    fs.mkdirSync(args.outDirectory, { recursive: true });
+    await plugin.saveImage(path.join(args.outDirectory, 'basic.png'));
+    await plugin.saveImage(path.join(args.outDirectory, 'basic.jpg'));
+    await plugin.saveImage(path.join(args.outDirectory, 'large.png'), { width: 1600, height: 1200 });
+    await plugin.saveImage(path.join(args.outDirectory, 'large.jpg'), { width: 1600, height: 1200 });
+    await plugin.saveImage(path.join(args.outDirectory, 'stylized.png'), undefined, STYLIZED_POSTPROCESSING);
+    await plugin.saveImage(path.join(args.outDirectory, 'stylized.jpg'), undefined, STYLIZED_POSTPROCESSING);
+    await plugin.saveImage(path.join(args.outDirectory, 'stylized-compressed-jpg.jpg'), undefined, STYLIZED_POSTPROCESSING, undefined, 10);
+
+    // Export state loadable in Mol* Viewer
+    await plugin.saveStateSnapshot(path.join(args.outDirectory, 'molstar-state.molj'));
+
+    // Cleanup
+    await plugin.clear();
+    plugin.dispose();
+}
+
+main();
diff --git a/src/mol-canvas3d/renderer.ts b/src/mol-canvas3d/renderer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..00bf5ed0d114ead669d4c1cae5b39a3395aafa9a
--- /dev/null
+++ b/src/mol-canvas3d/renderer.ts
@@ -0,0 +1,223 @@
+/**
+ * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author Jesse Liang <jesse.liang@rcsb.org>
+ * @author Sebastian Bittrich <sebastian.bittrich@rcsb.org>
+ * @author Ke Ma <mark.ma@rcsb.org>
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import * as fs from 'fs';
+import path from 'path';
+
+import { type BufferRet as JpegBufferRet } from 'jpeg-js'; // Only import type here, the actual import is done by LazyImports
+import { type PNG } from 'pngjs'; // Only import type here, the actual import is done by LazyImports
+
+import { createContext } from '../mol-gl/webgl/context';
+import { AssetManager } from '../mol-util/assets';
+import { ColorNames } from '../mol-util/color/names';
+import { PixelData } from '../mol-util/image';
+import { InputObserver } from '../mol-util/input/input-observer';
+import { LazyImports } from '../mol-util/lazy-imports';
+import { ParamDefinition } from '../mol-util/param-definition';
+import { Canvas3D, Canvas3DContext, Canvas3DProps, DefaultCanvas3DParams } from './canvas3d';
+import { ImagePass, ImageProps } from './passes/image';
+import { Passes } from './passes/passes';
+import { PostprocessingParams, PostprocessingProps } from './passes/postprocessing';
+
+
+const lazyImports = LazyImports.create('gl', 'jpeg-js', 'pngjs') as {
+    'gl': typeof import('gl'),
+    'jpeg-js': typeof import('jpeg-js'),
+    'pngjs': typeof import('pngjs'),
+};
+
+
+export type ImageRendererOptions = {
+    webgl?: WebGLContextAttributes,
+    canvas?: Partial<Canvas3DProps>,
+    imagePass?: Partial<ImageProps>,
+}
+
+export type RawImageData = {
+    data: Uint8ClampedArray,
+    width: number,
+    height: number,
+}
+
+
+/** To render Canvas3D when running in Node.js (without DOM) */
+export class Canvas3DRenderer {
+    readonly canvas3d: Canvas3D;
+    readonly imagePass: ImagePass;
+
+    constructor(readonly canvasSize: { width: number, height: number }, canvas3d?: Canvas3D, options?: ImageRendererOptions) {
+        if (canvas3d) {
+            this.canvas3d = canvas3d;
+        } else {
+            const glContext = lazyImports.gl(this.canvasSize.width, this.canvasSize.height, options?.webgl ?? defaultWebGLAttributes());
+            const webgl = createContext(glContext);
+            const input = InputObserver.create();
+            const attribs = { ...Canvas3DContext.DefaultAttribs };
+            const passes = new Passes(webgl, new AssetManager(), attribs);
+            this.canvas3d = Canvas3D.create({ webgl, input, passes, attribs } as Canvas3DContext, options?.canvas ?? defaultCanvas3DParams());
+        }
+
+        this.imagePass = this.canvas3d.getImagePass(options?.imagePass ?? defaultImagePassParams());
+        this.imagePass.setSize(this.canvasSize.width, this.canvasSize.height);
+    }
+
+    private getImageData(width: number, height: number): RawImageData {
+        this.imagePass.setSize(width, height);
+        this.imagePass.render();
+        this.imagePass.colorTarget.bind();
+
+        const array = new Uint8Array(width * height * 4);
+        this.canvas3d.webgl.readPixels(0, 0, width, height, array);
+        const pixelData = PixelData.create(array, width, height);
+        PixelData.flipY(pixelData);
+        PixelData.divideByAlpha(pixelData);
+        // ImageData is not defined in Node.js
+        return { data: new Uint8ClampedArray(array), width, height };
+    }
+
+    async getImageRaw(imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>): Promise<RawImageData> {
+        const width = imageSize?.width ?? this.canvasSize.width;
+        const height = imageSize?.height ?? this.canvasSize.height;
+        this.canvas3d.commit(true);
+        this.imagePass.setProps({
+            postprocessing: ParamDefinition.merge(PostprocessingParams, this.canvas3d.props.postprocessing, postprocessing),
+        });
+        return this.getImageData(width, height);
+    }
+
+    async getImagePng(imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>): Promise<PNG> {
+        const imageData = await this.getImageRaw(imageSize, postprocessing);
+        const generatedPng = new lazyImports.pngjs.PNG({ width: imageData.width, height: imageData.height });
+        generatedPng.data = Buffer.from(imageData.data.buffer);
+        return generatedPng;
+    }
+
+    async getImageJpeg(imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>, jpegQuality: number = 90): Promise<JpegBufferRet> {
+        const imageData = await this.getImageRaw(imageSize, postprocessing);
+        const generatedJpeg = lazyImports['jpeg-js'].encode(imageData, jpegQuality);
+        return generatedJpeg;
+    }
+
+    async saveImage(outPath: string, imageSize?: { width: number, height: number }, postprocessing?: Partial<PostprocessingProps>, format?: 'png' | 'jpeg', jpegQuality = 90) {
+        if (!format) {
+            const extension = path.extname(outPath).toLowerCase();
+            if (extension === '.png') format = 'png';
+            else if (extension === '.jpg' || extension === '.jpeg') format = 'jpeg';
+            else throw new Error(`Cannot guess image format from file path '${outPath}'. Specify format explicitly or use path with one of these extensions: .png, .jpg, .jpeg`);
+        }
+        if (format === 'png') {
+            const generatedPng = await this.getImagePng(imageSize, postprocessing);
+            await writePngFile(generatedPng, outPath);
+        } else if (format === 'jpeg') {
+            const generatedJpeg = await this.getImageJpeg(imageSize, postprocessing, jpegQuality);
+            await writeJpegFile(generatedJpeg, outPath);
+        } else {
+            throw new Error(`Invalid format: ${format}`);
+        }
+    }
+}
+
+async function writePngFile(png: PNG, outPath: string) {
+    await new Promise<void>(resolve => {
+        png.pack().pipe(fs.createWriteStream(outPath)).on('finish', resolve);
+    });
+}
+async function writeJpegFile(jpeg: JpegBufferRet, outPath: string) {
+    await new Promise<void>(resolve => {
+        fs.writeFile(outPath, jpeg.data, () => resolve());
+    });
+}
+
+export function defaultCanvas3DParams(): Partial<Canvas3DProps> {
+    return {
+        camera: {
+            mode: 'orthographic',
+            helper: {
+                axes: { name: 'off', params: {} }
+            },
+            stereo: {
+                name: 'off', params: {}
+            },
+            fov: 90,
+            manualReset: false,
+        },
+        cameraResetDurationMs: 0,
+        cameraFog: {
+            name: 'on',
+            params: {
+                intensity: 50
+            }
+        },
+        renderer: {
+            ...DefaultCanvas3DParams.renderer,
+            backgroundColor: ColorNames.white,
+        },
+        postprocessing: {
+            occlusion: {
+                name: 'off', params: {}
+            },
+            outline: {
+                name: 'off', params: {}
+            },
+            antialiasing: {
+                name: 'fxaa',
+                params: {
+                    edgeThresholdMin: 0.0312,
+                    edgeThresholdMax: 0.063,
+                    iterations: 12,
+                    subpixelQuality: 0.3
+                }
+            },
+            background: { variant: { name: 'off', params: {} } },
+            shadow: { name: 'off', params: {} },
+        }
+    };
+}
+
+export function defaultWebGLAttributes(): WebGLContextAttributes {
+    return {
+        antialias: true,
+        preserveDrawingBuffer: true,
+        alpha: true, // the renderer requires an alpha channel
+        depth: true, // the renderer requires a depth buffer
+        premultipliedAlpha: true, // the renderer outputs PMA
+    };
+}
+
+export function defaultImagePassParams(): Partial<ImageProps> {
+    return {
+        cameraHelper: {
+            axes: { name: 'off', params: {} },
+        },
+        multiSample: {
+            mode: 'on',
+            sampleLevel: 4
+        }
+    };
+}
+
+export const STYLIZED_POSTPROCESSING: Partial<PostprocessingProps> = {
+    occlusion: {
+        name: 'on' as const, params: {
+            samples: 32,
+            radius: 5,
+            bias: 0.8,
+            blurKernelSize: 15,
+            resolutionScale: 1,
+        }
+    }, outline: {
+        name: 'on' as const, params: {
+            scale: 1,
+            threshold: 0.95,
+            color: ColorNames.black,
+            includeTransparent: true,
+        }
+    }
+};
diff --git a/src/mol-plugin/headless-plugin-context.ts b/src/mol-plugin/headless-plugin-context.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dd765ecb65d278e5d1b457dc8ae1e4457509c85f
--- /dev/null
+++ b/src/mol-plugin/headless-plugin-context.ts
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ */
+
+import * as fs from 'fs';
+
+import { Canvas3D } from '../mol-canvas3d/canvas3d';
+import { PostprocessingProps } from '../mol-canvas3d/passes/postprocessing';
+import { Canvas3DRenderer, ImageRendererOptions } from '../mol-canvas3d/renderer';
+import { PluginContext } from './context';
+import { PluginSpec } from './spec';
+
+
+/** PluginContext that can be used in Node.js (without DOM) */
+export class HeadlessPluginContext extends PluginContext {
+    renderer: Canvas3DRenderer;
+
+    constructor(spec: PluginSpec, canvasSize: { width: number, height: number } = { width: 640, height: 480 }, rendererOptions?: ImageRendererOptions) {
+        super(spec);
+        this.renderer = new Canvas3DRenderer(canvasSize, undefined, rendererOptions);
+        (this.canvas3d as Canvas3D) = this.renderer.canvas3d;
+    }
+
+    /** Render the current plugin state to a PNG or JPEG file */
+    async saveImage(outPath: string, imageSize?: { width: number, height: number }, props?: Partial<PostprocessingProps>, format?: 'png' | 'jpeg', jpegQuality = 90) {
+        this.canvas3d!.commit(true);
+        return await this.renderer.saveImage(outPath, imageSize, props, format, jpegQuality);
+    }
+
+    /** Get the current plugin state */
+    getStateSnapshot() {
+        this.canvas3d!.commit(true);
+        return this.managers.snapshot.getStateSnapshot({ params: {} });
+    }
+
+    /** Save the current plugin state to a MOLJ file */
+    async saveStateSnapshot(outPath: string) {
+        const snapshot = this.getStateSnapshot();
+        const snapshot_json = JSON.stringify(snapshot, null, 2);
+        await new Promise<void>(resolve => {
+            fs.writeFile(outPath, snapshot_json, () => resolve());
+        });
+    }
+}
diff --git a/src/mol-util/assets.ts b/src/mol-util/assets.ts
index 8702ce6a9fd1581f6d367012ddf283c1f3119d06..7a14df662a9d8741123f398bc0d925bf5dd17500 100644
--- a/src/mol-util/assets.ts
+++ b/src/mol-util/assets.ts
@@ -9,6 +9,7 @@ import { UUID } from './uuid';
 import { iterableToArray } from '../mol-data/util';
 import { ajaxGet, DataType, DataResponse, readFromFile } from './data-source';
 import { Task } from '../mol-task';
+import { File_ as File } from './nodejs-browser-io';
 
 export { AssetManager, Asset };
 
diff --git a/src/mol-util/data-source.ts b/src/mol-util/data-source.ts
index 265bfaa804aac3ef1f1e85775ae3a549831895ec..37f6cb983ee2907aa180d333d3a268232662071a 100644
--- a/src/mol-util/data-source.ts
+++ b/src/mol-util/data-source.ts
@@ -7,21 +7,14 @@
  * Adapted from LiteMol
  */
 
+import * as fs from 'fs';
+
 import { Task, RuntimeContext } from '../mol-task';
 import { unzip, ungzip } from './zip/zip';
 import { utf8Read } from '../mol-io/common/utf8';
 import { AssetManager, Asset } from './assets';
+import { RUNNING_IN_NODEJS, File_ as File, XMLHttpRequest_ as XMLHttpRequest } from './nodejs-browser-io';
 
-// polyfill XMLHttpRequest in node.js
-const XHR = typeof document === 'undefined' ? require('xhr2') as {
-    prototype: XMLHttpRequest;
-    new(): XMLHttpRequest;
-    readonly DONE: number;
-    readonly HEADERS_RECEIVED: number;
-    readonly LOADING: number;
-    readonly OPENED: number;
-    readonly UNSENT: number;
-} : XMLHttpRequest;
 
 export enum DataCompressionMethod {
     None,
@@ -68,7 +61,7 @@ export function ajaxGet<T extends DataType>(params: AjaxGetParams<T> | string) {
 export type AjaxTask = typeof ajaxGet
 
 function isDone(data: XMLHttpRequest | FileReader) {
-    if (data instanceof FileReader) {
+    if (!RUNNING_IN_NODEJS && data instanceof FileReader) { // FileReader is not available in Node.js
         return data.readyState === FileReader.DONE;
     } else if (data instanceof XMLHttpRequest) {
         return data.readyState === XMLHttpRequest.DONE;
@@ -144,10 +137,8 @@ async function decompress(ctx: RuntimeContext, data: Uint8Array, compression: Da
     }
 }
 
-async function processFile<T extends DataType>(ctx: RuntimeContext, reader: FileReader, type: T, compression: DataCompressionMethod): Promise<DataResponse<T>> {
-    const { result } = reader;
-
-    let data = result instanceof ArrayBuffer ? new Uint8Array(result) : result;
+async function processFile<T extends DataType>(ctx: RuntimeContext, fileContent: string | ArrayBuffer | null, type: T, compression: DataCompressionMethod): Promise<DataResponse<T>> {
+    let data = fileContent instanceof ArrayBuffer ? new Uint8Array(fileContent) : fileContent;
     if (data === null) throw new Error('no data given');
 
     if (compression !== DataCompressionMethod.None) {
@@ -177,6 +168,9 @@ async function processFile<T extends DataType>(ctx: RuntimeContext, reader: File
 }
 
 function readFromFileInternal<T extends DataType>(file: File, type: T): Task<DataResponse<T>> {
+    if (RUNNING_IN_NODEJS) {
+        return readFromFileInternal_NodeJS(file, type);
+    }
     let reader: FileReader | undefined = void 0;
     return Task.create('Read File', async ctx => {
         try {
@@ -194,7 +188,7 @@ function readFromFileInternal<T extends DataType>(file: File, type: T): Task<Dat
             const fileReader = await readData(ctx, 'Reading...', reader);
 
             await ctx.update({ message: 'Processing file...', canAbort: false });
-            return await processFile(ctx, fileReader, type, compression);
+            return await processFile(ctx, fileReader.result, type, compression);
         } finally {
             reader = void 0;
         }
@@ -202,6 +196,22 @@ function readFromFileInternal<T extends DataType>(file: File, type: T): Task<Dat
         if (reader) reader.abort();
     });
 }
+function readFromFileInternal_NodeJS<T extends DataType>(file: File, type: T): Task<DataResponse<T>> {
+    return Task.create('Read File', async ctx => {
+        // unzipping for type 'zip' handled explicitly in `processFile`
+        const compression = type === 'zip' ? DataCompressionMethod.None : getCompression(file.name);
+
+        await ctx.update({ message: 'Opening file...', canAbort: false });
+        let content: ArrayBuffer | string;
+        if (type === 'binary' || type === 'zip' || compression !== DataCompressionMethod.None) {
+            content = await file.arrayBuffer();
+        } else {
+            content = await file.text();
+        }
+        await ctx.update({ message: 'Processing file...', canAbort: false });
+        return await processFile(ctx, content, type, compression);
+    });
+}
 
 class RequestPool {
     private static pool: XMLHttpRequest[] = [];
@@ -211,7 +221,7 @@ class RequestPool {
         if (this.pool.length) {
             return this.pool.pop()!;
         }
-        return new XHR();
+        return new XMLHttpRequest();
     }
 
     static emptyFunc() { }
@@ -259,6 +269,9 @@ function getRequestResponseType(type: DataType): XMLHttpRequestResponseType {
 }
 
 function ajaxGetInternal<T extends DataType>(title: string | undefined, url: string, type: T, body?: string, headers?: [string, string][]): Task<DataResponse<T>> {
+    if (RUNNING_IN_NODEJS && url.startsWith('file://')) {
+        return ajaxGetInternal_file_NodeJS(title, url, type, body, headers);
+    }
     let xhttp: XMLHttpRequest | undefined = void 0;
     return Task.create(title ? title : 'Download', async ctx => {
         xhttp = RequestPool.get();
@@ -288,6 +301,16 @@ function ajaxGetInternal<T extends DataType>(title: string | undefined, url: str
     });
 }
 
+/** Alternative implementation of ajaxGetInternal (because xhr2 does not support file:// protocol) */
+function ajaxGetInternal_file_NodeJS<T extends DataType>(title: string | undefined, url: string, type: T, body?: string, headers?: [string, string][]): Task<DataResponse<T>> {
+    if (!RUNNING_IN_NODEJS) throw new Error('This function should only be used when running in Node.js');
+    if (!url.startsWith('file://')) throw new Error('This function is only for URLs with protocol file://');
+    const filename = url.substring('file://'.length);
+    const data = fs.readFileSync(filename);
+    const file = new File([data], 'raw-data');
+    return readFromFile(file, type);
+}
+
 export type AjaxGetManyEntry = { kind: 'ok', id: string, result: Asset.Wrapper<'string' | 'binary'> } | { kind: 'error', id: string, error: any }
 export async function ajaxGetMany(ctx: RuntimeContext, assetManager: AssetManager, sources: { id: string, url: Asset.Url | string, isBinary?: boolean, canFail?: boolean }[], maxConcurrency: number) {
     const len = sources.length;
diff --git a/src/mol-util/lazy-imports.ts b/src/mol-util/lazy-imports.ts
new file mode 100644
index 0000000000000000000000000000000000000000..61e2619e3bb945bb40867db22055406825241fb1
--- /dev/null
+++ b/src/mol-util/lazy-imports.ts
@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) 2019-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ *
+ * Manage dependencies which are not listed in `package.json` for performance reasons.
+ */
+
+
+const _loadedExtraPackages: { [dep: string]: any } = {};
+
+/** Define imports that only get imported when first needed.
+ * Example usage:
+ * ```
+ * const lazyImports = LazyImports.create('gl', 'jpeg-js', 'pngjs') as {
+ *     'gl': typeof import('gl'),
+ *     'jpeg-js': typeof import('jpeg-js'),
+ *     'pngjs': typeof import('pngjs'),
+ * };
+ * ...
+ * lazyImports.pngjs.blablabla("I'm being imported now");
+ * lazyImports.pngjs.blablabla("I'm cached :D");
+ * ```
+ */
+export class LazyImports {
+    static create<U extends string>(...packages: U[]): { [dep in U]: any } {
+        return new LazyImports(packages) as any;
+    }
+    private constructor(private packages: string[]) {
+        for (const p of packages) {
+            Object.defineProperty(this, p, {
+                get: () => this.getPackage(p),
+            });
+        }
+    }
+    private getPackage(packageName: string) {
+        if (!_loadedExtraPackages[packageName]) {
+            try {
+                _loadedExtraPackages[packageName] = require(packageName);
+            } catch {
+                const message = `Package '${packageName}' is not installed. (Some packages are not listed in the 'molstar' package dependencies for performance reasons. If you're seeing this error, you'll probably need them. If your project depends on 'molstar', add these to your dependencies: ${this.packages.join(', ')}. If you're running 'molstar' directly, run this: npm install --no-save ${this.packages.join(' ')})`;
+                throw new Error(message);
+            }
+        }
+        return _loadedExtraPackages[packageName];
+    }
+}
diff --git a/src/mol-util/nodejs-browser-io.ts b/src/mol-util/nodejs-browser-io.ts
new file mode 100644
index 0000000000000000000000000000000000000000..973113efa5964877b7429437f04ff4706bf691d8
--- /dev/null
+++ b/src/mol-util/nodejs-browser-io.ts
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Adam Midlik <midlik@gmail.com>
+ *
+ * Implements some browser-only global variables for Node.js environment.
+ * These workarounds will also work in browsers as usual.
+ */
+
+
+/** Determines whether the current code is running in Node.js or in a browser */
+export const RUNNING_IN_NODEJS = typeof document === 'undefined';
+
+/** Like `XMLHttpRequest` but works also in Node.js */
+export const XMLHttpRequest_ = getXMLHttpRequest();
+
+/** Like `File` but works also in Node.js */
+export const File_ = getFile();
+
+
+function getXMLHttpRequest(): typeof XMLHttpRequest {
+    if (typeof document === 'undefined') {
+        return require('xhr2');
+    } else {
+        return XMLHttpRequest;
+    }
+}
+
+function getFile(): typeof File {
+    if (typeof document === 'undefined') {
+        class File_NodeJs implements File {
+            private readonly blob: Blob;
+            // Blob fields
+            readonly size: number;
+            readonly type: string;
+            arrayBuffer() { return this.blob.arrayBuffer(); }
+            slice(start?: number, end?: number, contentType?: string) { return this.blob.slice(start, end, contentType); }
+            stream() { return this.blob.stream(); }
+            text() { return this.blob.text(); }
+            // File fields
+            name: string;
+            lastModified: number;
+            webkitRelativePath: string;
+
+            constructor(fileBits: BlobPart[], fileName: string, options?: FilePropertyBag) {
+                this.blob = new Blob(fileBits, options);
+                // Blob fields
+                this.size = this.blob.size;
+                this.type = this.blob.type;
+                // File fields
+                this.name = fileName;
+                this.lastModified = options?.lastModified ?? 0;
+                this.webkitRelativePath = '';
+            }
+        }
+        return File_NodeJs;
+    } else {
+        return File;
+    }
+}