Skip to content

Latest commit

 

History

History
615 lines (486 loc) · 21 KB

File metadata and controls

615 lines (486 loc) · 21 KB

Multi-Runtime Platform Abstraction

Overview

Workglow is designed to run identically across three JavaScript runtimes: browsers, Node.js, and Bun. Rather than relying on runtime detection at import time or bundling platform polyfills into a single artifact, the framework uses a compile-time entry point pattern combined with Node.js conditional exports to deliver the correct platform-specific code to each runtime. The result is zero unnecessary polyfill code in any target environment, smaller bundle sizes, and the ability to leverage native APIs (Web Workers, worker_threads, CompressionStream, zlib, OffscreenCanvas, sharp, etc.) without degrading the experience on other platforms.

The abstraction is anchored in @workglow/util, the foundation package of the monorepo, and the pattern it establishes is replicated by every other package in the dependency graph. This document explains the architecture in detail: entry point conventions, conditional export configuration, the shared common module, platform-specific modules for media/compress/workers, the build pipeline, and how to extend the system with new platform-specific code.

Source files referenced in this document:

File Purpose
packages/util/src/common.ts Shared exports used by all three runtimes
packages/util/src/browser.ts Browser entry point
packages/util/src/node.ts Node.js entry point
packages/util/src/bun.ts Bun entry point
packages/util/package.json Conditional exports and build scripts
packages/util/tsconfig.json TypeScript configuration listing all entry points

Entry Point Pattern

Every package in the Workglow monorepo follows a three-file entry point convention:

src/
  browser.ts    # Browser entry point
  node.ts       # Node.js entry point
  bun.ts        # Bun entry point
  common.ts     # Shared logic re-exported by all three

Each platform entry point re-exports everything from common.ts and then layers on platform-specific modules. For @workglow/util, the entry points look like this:

browser.ts:

export * from "./common";
export * from "./worker/Worker.browser";

node.ts:

export * from "./common";
export * from "./worker/Worker.node";

bun.ts:

export * from "./common";
export * from "./worker/Worker.bun";

This pattern guarantees that the common API surface is identical across all three runtimes — the only difference is how platform-specific concerns (workers, image processing, compression, etc.) are implemented. Consumers import from the package name (@workglow/util) and the runtime automatically receives the correct entry point thanks to conditional exports.


Conditional Exports

The "exports" field in package.json is the mechanism that maps the "." import specifier to the correct built artifact for each runtime. The resolution order within a condition block matters: bundlers and runtimes match the first condition they support.

Main export (".")

{
  ".": {
    "react-native": {
      "types": "./dist/browser.d.ts",
      "import": "./dist/browser.js"
    },
    "browser": {
      "types": "./dist/browser.d.ts",
      "import": "./dist/browser.js"
    },
    "bun": {
      "types": "./dist/bun.d.ts",
      "import": "./dist/bun.js"
    },
    "types": "./dist/node.d.ts",
    "import": "./dist/node.js"
  }
}

Resolution rules:

  1. React Native and browser bundlers (Vite, webpack, esbuild with browser condition) resolve to dist/browser.js.
  2. Bun resolves to dist/bun.js.
  3. Everything else (Node.js, fallback) resolves to dist/node.js.

Each condition block includes a "types" field so that TypeScript resolves the correct .d.ts file for the platform. This is critical because the type declarations differ per platform — for example, the browser entry exports Worker as globalThis.Worker while the Node entry exports a WorkerPolyfill that wraps worker_threads.

Sub-path exports

@workglow/util exposes additional sub-paths beyond ".", each with their own platform conditions where appropriate:

Sub-path Description Platform-specific?
@workglow/util Core utilities, DI, events, logging, crypto, workers Yes (worker impl)
@workglow/util/schema JSON Schema types, validation, vector/tensor math No
@workglow/util/graph Graph data structures (Graph, DirectedGraph, DAG) No
@workglow/util/media Platform-specific image handling Yes
@workglow/util/compress Platform-specific compression Yes
@workglow/util/worker Lightweight worker entry point Yes

Sub-paths that are not platform-specific (schema, graph) are built once with --target=browser and served to all runtimes — no conditional branching needed.

Sub-paths that are platform-specific use the same condition structure as the main export:

{
  "./media": {
    "react-native": {
      "types": "./dist/media-browser.d.ts",
      "import": "./dist/media-browser.js"
    },
    "browser": {
      "types": "./dist/media-browser.d.ts",
      "import": "./dist/media-browser.js"
    },
    "bun": {
      "types": "./dist/media-node.d.ts",
      "import": "./dist/media-node.js"
    },
    "types": "./dist/media-node.d.ts",
    "import": "./dist/media-node.js"
  }
}

Note that Bun shares the Node.js media implementation (media-node.js) because both runtimes have access to the same server-side image APIs. This is a common pattern — Bun and Node often share an implementation while the browser diverges.


Common Module

common.ts is the shared core that all three entry points re-export. It contains everything that does not depend on platform-specific APIs:

export * from "./crypto/Crypto";
export * from "./di";
export * from "./events/EventEmitter";
export * from "./logging";
export * from "./utilities/BaseError";
export * from "./utilities/Misc";
export * from "./utilities/objectOfArraysAsArrayOfObjects";
export * from "./utilities/TypeUtilities";
export * from "./worker/WorkerManager";
export * from "./credentials";
export * from "./crypto/WebCrypto";
export * from "./telemetry";

This includes the dependency injection system (ServiceRegistry, globalServiceRegistry, createServiceToken), the EventEmitter, logging infrastructure, cryptographic utilities (using the Web Crypto API which is available on all modern runtimes), credential management, telemetry, the WorkerManager class, and general-purpose utility types and functions.

The WorkerManager itself lives in common because its API is platform-agnostic — it accepts Worker instances and communicates via postMessage/addEventListener. The platform-specific part is which Worker class is used, and that is resolved by the platform entry point.


Platform-Specific Modules

Workers

The worker abstraction is the most prominent example of platform divergence. Each platform needs a different Worker class and a corresponding WorkerServer that listens for messages on the worker side.

Browser (Worker.browser.ts):

Uses the standard globalThis.Worker and self as the parent port. The WorkerServer listens via self.addEventListener("message", ...).

const Worker = globalThis.Worker;
const parentPort = self;
export { Worker, parentPort };

export class WorkerServer extends WorkerServerBase {
  constructor() {
    parentPort?.addEventListener("message", async (event) => {
      await this.handleMessage({ type: event.type, data: event.data });
    });
    super();
  }
}

Node.js (Worker.node.ts):

Wraps worker_threads.Worker in a WorkerPolyfill that normalizes the API to match the browser Worker interface (adding addEventListener/removeEventListener methods and converting file paths to file:// URLs):

import { Worker as NodeWorker, isMainThread, parentPort } from "worker_threads";
import { pathToFileURL } from "url";

class WorkerPolyfill extends NodeWorker {
  constructor(scriptUrl: string | URL, options?: WorkerOptions) {
    const resolved = scriptUrl instanceof URL
      ? scriptUrl.toString()
      : pathToFileURL(scriptUrl).toString();
    super(resolved, options);
  }

  addEventListener(event: "message" | "error", listener: (...args: any[]) => void) {
    if (event === "message") this.on("message", listener);
    if (event === "error") this.on("error", listener);
  }

  removeEventListener(event: "message" | "error", listener: (...args: any[]) => void) {
    if (event === "message") this.off("message", listener);
    if (event === "error") this.off("error", listener);
  }
}

const Worker = isMainThread ? WorkerPolyfill : parentPort;
export { Worker, parentPort };

Bun (Worker.bun.ts):

Bun natively supports globalThis.Worker with the same API as the browser, so the Bun worker implementation is identical to the browser one.

All three implementations register a WorkerServer singleton into globalServiceRegistry under the WORKER_SERVER service token, ensuring the correct server is available in worker contexts regardless of platform.

WorkerManager

The WorkerManager class (in common.ts, platform-agnostic) manages the lifecycle of worker instances on the main thread:

import { WORKER_MANAGER } from "@workglow/util";

const manager = globalServiceRegistry.get(WORKER_MANAGER);
manager.registerWorker("my-worker", () => new Worker("./worker.js"));

const result = await manager.callWorkerFunction<string>("my-worker", "processData", [input]);

Key features:

  • Lazy initialization: Workers can be registered with a factory function and are only constructed when first called.
  • Ready handshake: Workers send a ready message advertising their registered functions; the manager waits for this before dispatching calls.
  • Three call modes: callWorkerFunction (request/response), callWorkerStreamFunction (async generator yielding stream chunks), and callWorkerReactiveFunction (lightweight preview with no abort support).
  • Abort support: Callers can pass an AbortSignal; the manager forwards abort messages to the worker.
  • Progress tracking: Workers can send progress updates during long-running operations.
  • Transferable detection: The WorkerServerBase automatically extracts TypedArray buffers, OffscreenCanvas, ImageBitmap, VideoFrame, and MessagePort transferables from results for zero-copy transfer back to the main thread.

Worker Entry Point (@workglow/util/worker)

A separate lightweight sub-path export is provided for code that runs inside workers. It re-exports only the minimal subset needed by worker code — DI, logging, WorkerServerBase, WorkerManager, and partial JSON parsing — without the heavy JSON Schema validation libraries that would bloat worker bundles:

// In a worker file:
import { globalServiceRegistry, WORKER_SERVER } from "@workglow/util/worker";
import type { WorkerServerBase } from "@workglow/util/worker";

const server = globalServiceRegistry.get(WORKER_SERVER);
server.registerFunction("processData", async (input, model, onProgress, signal) => {
  // ... process and return result
});
server.sendReady();

Media (@workglow/util/media)

The media sub-path provides a convertImageDataToUseableForm function that converts between image representations. The function signature is identical across platforms, but the supported conversions differ:

Shared types (media/image.ts):

export type ImageChannels = 1 | 3 | 4; // grayscale, rgb, rgba
export type ImageDataSupport =
  | "Blob" | "ImageBinary" | "ImageBitmap" | "OffscreenCanvas"
  | "VideoFrame" | "RawImage" | "DataUri" | "Sharp";

export interface ImageBinary {
  data: Uint8ClampedArray;
  width: number;
  height: number;
  channels: ImageChannels;
}

Browser (media/image.browser.ts):

Supports ImageBitmap, OffscreenCanvas, VideoFrame, Blob, DataUri, and ImageBinary. Uses createImageBitmap() and OffscreenCanvas for conversions — APIs only available in browser contexts.

Node.js (media/image.node.ts):

Supports Blob, ImageBinary, and DataUri. Does not use browser-only APIs like ImageBitmap or OffscreenCanvas. Server-side image processing can use the Sharp format when the sharp library is available.

Compress (@workglow/util/compress)

The compress sub-path exposes compress and decompress functions with identical signatures across platforms:

export async function compress(
  input: string | Uint8Array,
  algorithm: "gzip" | "br" = "gzip"
): Promise<Uint8Array>;

export async function decompress(
  input: Uint8Array,
  algorithm: "gzip" | "br" = "gzip"
): Promise<string>;

Browser (compress/compress.browser.ts):

Uses the Web Streams API with CompressionStream / DecompressionStream:

const compressedStream = sourceBlob
  .stream()
  .pipeThrough(new CompressionStream(algorithm));
const compressedBuffer = await new Response(compressedStream).arrayBuffer();
return new Uint8Array(compressedBuffer);

Node.js (compress/compress.node.ts):

Uses the built-in zlib module with gzip/gunzip and brotliCompress/brotliDecompress:

import zlib from "zlib";
import { promisify } from "util";

const compressFn = algorithm === "br" ? zlib.brotliCompress : zlib.gzip;
const result = await promisify(compressFn)(Buffer.from(input));
return new Uint8Array(result.buffer, result.byteOffset, result.byteLength);

Build Configuration

Each entry point is compiled separately by bun build with the matching --target flag. The @workglow/util package runs all builds concurrently via concurrently:

# Build all JS targets concurrently
bun run build-js
# Which expands to:
concurrently \
  'bun build --target=browser --sourcemap=external --packages=external --outdir ./dist ./src/browser.ts' \
  'bun build --target=node    --sourcemap=external --packages=external --outdir ./dist ./src/node.ts' \
  'bun build --target=bun     --sourcemap=external --packages=external --outdir ./dist ./src/bun.ts' \
  'bun build --target=browser --sourcemap=external --packages=external --outdir ./dist ./src/schema-entry.ts' \
  'bun build --target=browser --sourcemap=external --packages=external --outdir ./dist ./src/graph-entry.ts' \
  # ... media, compress, worker targets

The --packages=external flag ensures that all dependencies are left as import statements in the output (not bundled), matching the expectations of the Node.js/Bun module resolvers and allowing tree-shaking in browser bundlers.

Type declarations are generated separately via tsgo (the native TypeScript compiler). The tsconfig.json lists all entry point files explicitly:

{
  "files": [
    "./src/node.ts",
    "./src/browser.ts",
    "./src/bun.ts",
    "./src/worker-entry.ts",
    "./src/schema-entry.ts",
    "./src/graph-entry.ts",
    "./src/media-browser.ts",
    "./src/media-node.ts",
    "./src/compress-browser.ts",
    "./src/compress-node.ts"
  ],
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

The composite: true and incremental: true settings enable TypeScript project references and build caching via .tsbuildinfo files.


Adding Platform-Specific Code

To add a new platform-specific module to @workglow/util (or any package), follow these steps:

1. Create the implementation files

src/
  myfeature/
    myfeature.ts            # Shared types and interfaces
    myfeature.browser.ts    # Browser implementation
    myfeature.node.ts       # Node.js implementation (often shared with Bun)

2. Create entry point files

src/
  myfeature-browser.ts     # exports * from "./myfeature/myfeature";
                            # exports * from "./myfeature/myfeature.browser";
  myfeature-node.ts        # exports * from "./myfeature/myfeature";
                            # exports * from "./myfeature/myfeature.node";

3. Add build scripts to package.json

{
  "build-myfeature": "concurrently 'bun run build-myfeature-browser' 'bun run build-myfeature-node'",
  "build-myfeature-browser": "bun build --target=browser --sourcemap=external --packages=external --outdir ./dist ./src/myfeature-browser.ts",
  "build-myfeature-node": "bun build --target=node --sourcemap=external --packages=external --outdir ./dist ./src/myfeature-node.ts"
}

Add the new script names to the build-js concurrently list.

4. Add conditional exports

{
  "./myfeature": {
    "react-native": {
      "types": "./dist/myfeature-browser.d.ts",
      "import": "./dist/myfeature-browser.js"
    },
    "browser": {
      "types": "./dist/myfeature-browser.d.ts",
      "import": "./dist/myfeature-browser.js"
    },
    "bun": {
      "types": "./dist/myfeature-node.d.ts",
      "import": "./dist/myfeature-node.js"
    },
    "types": "./dist/myfeature-node.d.ts",
    "import": "./dist/myfeature-node.js"
  }
}

5. Update tsconfig.json

Add the new entry point files to both the "files" array and (if applicable) the "include" patterns.

6. Maintain the same function signatures

Both platform implementations must export functions with identical names and compatible type signatures. Consumers should be able to write import { myFunction } from "@workglow/util/myfeature" without caring which platform they run on.


Testing

Platform-specific code requires testing on the target runtimes. The monorepo supports two test runners:

  • bun test — runs tests natively in Bun (also exercises browser-compatible code paths since Bun supports most Web APIs).
  • vitest — runs tests in a Node.js environment with optional browser mode.

Test files live in packages/test/src/test/. To run tests for a specific section:

bun scripts/test.ts util vitest       # Run util tests via vitest (Node.js)
bun scripts/test.ts util bun          # Run util tests via bun test

When testing platform-specific code, write tests against the public API surface (the function signatures exported from the sub-path) rather than importing from the platform-specific files directly. This ensures the conditional export resolution is exercised.

For worker-related tests, the test typically registers a worker, waits for the ready handshake, calls a function, and asserts the result:

import { describe, expect, it } from "vitest";
import { WorkerManager } from "@workglow/util";

describe("WorkerManager", () => {
  it("should call a worker function", async () => {
    const manager = new WorkerManager();
    manager.registerWorker("test", () => new Worker("./test-worker.js"));
    const result = await manager.callWorkerFunction<string>("test", "echo", ["hello"]);
    expect(result).toBe("hello");
  });
});

Package Configuration Reference

The complete conditional exports map for @workglow/util:

{
  "exports": {
    ".": {
      "react-native": { "types": "./dist/browser.d.ts", "import": "./dist/browser.js" },
      "browser":      { "types": "./dist/browser.d.ts", "import": "./dist/browser.js" },
      "bun":          { "types": "./dist/bun.d.ts",     "import": "./dist/bun.js" },
      "types": "./dist/node.d.ts",
      "import": "./dist/node.js"
    },
    "./schema": {
      "types": "./dist/schema-entry.d.ts",
      "import": "./dist/schema-entry.js"
    },
    "./graph": {
      "types": "./dist/graph-entry.d.ts",
      "import": "./dist/graph-entry.js"
    },
    "./media": {
      "react-native": { "types": "./dist/media-browser.d.ts", "import": "./dist/media-browser.js" },
      "browser":      { "types": "./dist/media-browser.d.ts", "import": "./dist/media-browser.js" },
      "bun":          { "types": "./dist/media-node.d.ts",    "import": "./dist/media-node.js" },
      "types": "./dist/media-node.d.ts",
      "import": "./dist/media-node.js"
    },
    "./compress": {
      "react-native": { "types": "./dist/compress-browser.d.ts", "import": "./dist/compress-browser.js" },
      "browser":      { "types": "./dist/compress-browser.d.ts", "import": "./dist/compress-browser.js" },
      "bun":          { "types": "./dist/compress-node.d.ts",    "import": "./dist/compress-node.js" },
      "types": "./dist/compress-node.d.ts",
      "import": "./dist/compress-node.js"
    },
    "./worker": {
      "react-native": { "types": "./dist/worker-browser.d.ts", "import": "./dist/worker-browser.js" },
      "browser":      { "types": "./dist/worker-browser.d.ts", "import": "./dist/worker-browser.js" },
      "bun":          { "types": "./dist/worker-bun.d.ts",     "import": "./dist/worker-bun.js" },
      "types": "./dist/worker-entry.d.ts",
      "import": "./dist/worker-node.js"
    }
  }
}

The "files" field limits the published package to dist/ and any inline Markdown documentation:

{
  "files": ["dist", "src/**/*.md"]
}

This keeps the published package lean — source code is not included, only compiled artifacts and type declarations.