Launch OpenGPEX

Plugin Workers

How to offload heavy computation (encoding, segmentation, custom algorithms) to dedicated Web Workers inside your plugin — without touching the v2 core engine Worker.


Design Principle

The v2 core runs a single shared engine Worker (core/engine/worker/entry.worker.ts → router.ts) for CPU-bound codec and analysis duties: decode, resampling, pixel extraction, and histogram analysis. GPU work — layer compositing, adjustments, filtering, and text rasterization — runs as shader passes on the main-thread WebGPU engine, not in the Worker. This Worker is core infrastructure — plugins must never invade it. Create your own Worker inside your plugin directory.

Benefits:

  • On-demand loading — users who never invoke your feature pay no startup cost
  • Dependency isolation — WASM modules and native libraries stay in your Worker chunk
  • Fault isolation — your Worker crashing does not disturb the engine pipeline
  • Bundler-friendly — new URL('./worker.ts', import.meta.url) triggers automatic code-splitting

Directory Structure

MyPlugin/
├── index.tsx
├── protocols.ts
├── commands.ts
├── hooks.ts
├── components.tsx
└── worker/                     # Plugin-private Worker directory
    ├── protocol.ts             # Message types (Request/Progress/Result/Error)
    ├── client.ts               # Main-thread client (lazy init, reqId dispatch)
    └── my-task.worker.ts       # Worker implementation

Two Lifecycle Modes

Mode A: Fire-and-Forget

For lightweight, infrequent, stateless tasks. Create, run, terminate:

// commands.ts
execute: async (ctx) => {
  ctx.scoped!.setBusy(true);
  try {
    const worker = new Worker(
      new URL('./worker/encode.worker.ts', import.meta.url),
      { type: 'module' }
    );
    const result = await new Promise((resolve, reject) => {
      worker.onmessage = (e) => { resolve(e.data); worker.terminate(); };
      worker.onerror = (e) => { reject(e); worker.terminate(); };
      worker.postMessage(payload, [transferable]);
    });
  } finally {
    ctx.scoped!.setBusy(false);
  }
}

Mode B: Persistent Singleton

For high-frequency tasks or expensive initialization. Lazy-create on first run(), destroy on timeout/crash/dispose(), and lazily rebuild on the next call. A singleton must have: an independent protocol.ts, an independent client.ts, reqId request correlation, and a timeout.

// worker/protocol.ts
export interface WorkerRequest {
  type: 'run';
  reqId: number;
  payload: ImageData;
}
export interface ProgressMessage {
  type: 'progress';
  reqId: number;
  stage: 'downloading' | 'processing' | 'postprocessing';
  progress: number; // 0.0 ~ 1.0
}
export interface ResultMessage {
  type: 'result';
  reqId: number;
  data: Uint8Array;
}
export interface ErrorMessage {
  type: 'error';
  reqId: number;
  error: string;
}
export type WorkerMessage = ProgressMessage | ResultMessage | ErrorMessage;
// worker/client.ts
let worker: Worker | null = null;
let reqCounter = 0;

function ensure(): Worker {
  if (!worker) {
    worker = new Worker(
      new URL('./my-task.worker.ts', import.meta.url),
      { type: 'module' }
    );
  }
  return worker;
}

export function run(payload: ImageData): Promise<Uint8Array> {
  const w = ensure();
  const reqId = ++reqCounter;
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      reject(new Error('Worker timeout'));
      dispose(); // next run() lazily rebuilds
    }, 30000);

    w.onmessage = (e: MessageEvent<WorkerMessage>) => {
      if (e.data.reqId !== reqId) return; // stale response guard
      if (e.data.type === 'result') {
        clearTimeout(timeout);
        resolve(e.data.data);
      } else if (e.data.type === 'error') {
        clearTimeout(timeout);
        reject(new Error(e.data.error));
      }
      // 'progress' messages can drive UI updates
    };
    w.postMessage({ type: 'run', reqId, payload }, [payload.data.buffer]);
  });
}

export function dispose() {
  worker?.terminate();
  worker = null;
}

Mandatory Rules

# Rule Reason
1 Transferable transport postMessage(msg, [buffer]) — zero-copy. Never structured-clone megabyte ArrayBuffers
2 Timeout protection Tasks expected to exceed 3s MUST have a timeout; on timeout, terminate() and surface the error
3 No main-thread blocking Never use Atomics.wait or synchronous waits for Worker responses
4 Error bubbling Catch inside the Worker and postMessage an error message; the main thread shows a toast/HUD
5 No DOM in Workers Workers cannot import React, document, or window — it crashes at startup
6 Non-zero output dimensions The engine's render loop silently skips any frame whose canvas has zero width or height (WebGpuEngine.ts safety check). Clamp all generated buffers to at least 1×1
7 Busy wrapping Wrap async Worker calls in ctx.scoped.setBusy(true/false) with a finally block

Progress Reporting

For long-running tasks, stream progress from the Worker:

// Inside the worker
self.postMessage({
  type: 'progress',
  reqId,
  stage: 'processing',
  progress: 0.45,
  detail: 'Filtering tiles...',
});

Forward progress to UI via a plugin signal (usePluginSignals) or a direct ref write for frame-rate-critical displays.


When To Use the Core Services Instead

Before writing your own Worker, check whether ctx.pixels already covers the operation. The core pipeline handles decode, resample, rasterize, composite, and extract with multi-level caching and GPU acceleration. Custom Workers are for what the engine does not provide: proprietary inference, custom codecs, domain-specific algorithms.


Next Steps


Last updated: 2026-10-12