worker/worker_client.js

import { asModel } from "../persist.js";

/**
 * Returns the shared client that runs create* functions (and other worker-side operations)
 * in a separate thread — a Web Worker in the browser, or a worker_threads Worker in Node —
 * via Comlink. Environment is detected automatically so the same call site works in both.
 *
 * The underlying worker is created once and reused by every caller: worker-side module state
 * (e.g. the location template registry used to expand shimmed locations) must stay consistent
 * across calls, so different parts of the app can't each get their own worker.
 *
 * @example
 * const client = await createWorkerClient();
 * const location = await client.call("createLocation", { type: "Farm", history: true });
 * await client.terminate();
 *
 * @returns {Promise<{call: Function, expand: Function, terminate: Function}>}
 */
export function createWorkerClient() {
  if (!clientPromise) {
    const isBrowser = typeof window !== "undefined" && typeof Worker !== "undefined";
    clientPromise = isBrowser ? createBrowserClient() : createNodeClient();
  }
  return clientPromise;
}

let clientPromise = null;

class WorkerClient {
  constructor(worker, api) {
    this.worker = worker;
    this.api = api;
  }
  async call(functionName, args) {
    const json = await this.api.call(functionName, args).then();
    return asModel(json);
  }
  async expand(location) {
    const json = await this.api.expand(location).then();
    return asModel(json);
  }
  /**
   * Callers are responsible for terminating explicitly when they're done with the client —
   * there's no reliable hook to do this automatically, though in the browser we also hook
   * into "beforeunload" which is sufficient unless working in something like an SPA for a
   * long period of time.
   */
  async terminate() {
    await this.worker.terminate();
    clientPromise = null;
  }
}

async function createBrowserClient() {
  const Comlink = await import("/lib/comlink.mjs");
  const worker = new Worker(new URL("./browser_worker.js", import.meta.url), {
    type: "module",
  });
  const api = Comlink.wrap(worker);
  const client = new WorkerClient(worker, api);
  window.addEventListener("beforeunload", () => client.terminate());
  return client;
}

async function createNodeClient() {
  const { Worker: NodeWorker } = await import("node:worker_threads");
  const Comlink = await import("comlink");
  const { default: nodeEndpoint } = await import("comlink/dist/esm/node-adapter.mjs");
  const worker = new NodeWorker(new URL("./node_worker.js", import.meta.url));
  const api = Comlink.wrap(nodeEndpoint(worker));
  return new WorkerClient(worker, api);
}