BROWSER_EXPORT_PLAN

@galaxy-tool-util/core Browser Export Plan

Date: 2026-04-12 Target repo: galaxy-tool-util-ts (worktree: /Users/jxc755/projects/worktrees/galaxy-tool-util/branch/gxwf-web) Target package: packages/core Motivation: Drop the four-file Node-builtin shim pile and esbuild resolve plugin currently in galaxy-workflows-vscode/server/{browser-shims,gx-workflow-ls-*/tsup.config.ts}. Root cause is upstream: @galaxy-tool-util/core re-exports Node-only code at its top-level entry, so every downstream bundler that targets the browser has to paper over it.


The actual problem

packages/core/src/index.ts re-exports everything from one module:

SymbolDefined inPulls in at top level
FilesystemCacheStoragecache/storage/filesystem.tsnode:fs, node:fs/promises, node:path
ToolCache, getCacheDir, DEFAULT_CACHE_DIRcache/tool-cache.tsnode:path, node:os (top-level join(homedir(), ...) expression — runs at module evaluation)
loadWorkflowToolConfigconfig.tsnode:fs/promises
IndexedDBCacheStoragecache/storage/indexeddb.ts(browser-safe)
ParsedTool, cacheKey, fetchFromToolShed, ToolInfoService, schemasvarious(browser-safe — cacheKey already uses Web Crypto)

Because cache/index.ts re-exports FilesystemCacheStorage and tool-cache.ts statically imports it (to use as default), a browser consumer importing IndexedDBCacheStorage from the package root still evaluates the filesystem module and the homedir() call. esbuild with platform: "browser" then either errors or externalizes node:*, which fails at runtime.

Design decisions (locked)

QuestionDecision
Entry shapeSubpath exports: @galaxy-tool-util/core/browser and @galaxy-tool-util/core/node. Root "." stays as a universal entry containing only symbols that are safe in both envs.
Default behavior of "."Universal — no FS, no Node builtins. Callers that want FilesystemCacheStorage / loadWorkflowToolConfig / getCacheDir import from /node.
"browser" conditionYes — in exports["."], add "browser" pointing at ./dist/index.browser.js as an alias of the universal entry. Keeps the path cheap; main benefit is guarding against accidental future creep of Node code into the universal entry.
sideEffectsSet "sideEffects": false on core’s package.json (verify — CacheIndex, schemas are pure). Enables tree-shaking for bundlers that still import everything.
Breaking change?Yes, narrow. FilesystemCacheStorage, getCacheDir, DEFAULT_CACHE_DIR, CACHE_DIR_ENV_VAR, loadWorkflowToolConfig move to /node. All current consumers (cli, gxwf-web, tool-cache-proxy) update one import line each. Ship as a minor bump with a changeset — pre-1.0 so breaking subpath is acceptable.
ToolCache default storageRemove the implicit new FilesystemCacheStorage(...) fallback from the ToolCache constructor. storage becomes a required option. This severs the static edge that pulls filesystem into the browser entry. Node callers get a makeNodeToolCache(opts) helper in /node that constructs the default.

Target file layout

packages/core/src/
  index.ts                    ← universal entry (renamed from current)
  node.ts                     ← NEW: Node-only entry
  cache/
    index.ts                  ← universal re-exports (no FilesystemCacheStorage)
    node.ts                   ← NEW: FilesystemCacheStorage, getCacheDir, DEFAULT_CACHE_DIR, makeNodeToolCache
    tool-cache.ts             ← storage now REQUIRED in constructor; no FilesystemCacheStorage import here
    tool-cache-defaults.ts    ← NEW: DEFAULT_TOOLSHED_URL, TOOLSHED_URL_ENV_VAR (no node: imports)
    cache-index.ts            ← unchanged (pure)
    cache-key.ts              ← unchanged (Web Crypto)
    tool-id.ts                ← unchanged (pure)
    storage/
      interface.ts            ← unchanged
      filesystem.ts           ← unchanged, imported ONLY from cache/node.ts
      indexeddb.ts            ← unchanged
  config.ts                   ← universal: schemas + toolInfoOptionsFromConfig only
  config-node.ts              ← NEW: loadWorkflowToolConfig (uses node:fs/promises)
  models/…                    ← unchanged (pure)
  client/…                    ← unchanged (uses global fetch, browser-safe)
  tool-info.ts                ← unchanged (pure)

package.json exports

{
  "type": "module",
  "sideEffects": false,
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "browser": "./dist/index.js",
      "import": "./dist/index.js"
    },
    "./node": {
      "types": "./dist/node.d.ts",
      "import": "./dist/node.js"
    },
    "./package.json": "./package.json"
  },
  "files": ["dist", "README.md", "LICENSE"]
}

Node subpath has no "browser" key — bundlers targeting the browser will fail fast if someone accidentally imports /node, which is the desired signal.


Phases

Phase 1 — Carve universal vs Node surfaces (no behavior change)

1.1 Create packages/core/src/cache/tool-cache-defaults.ts with DEFAULT_TOOLSHED_URL and TOOLSHED_URL_ENV_VAR. Re-export from tool-cache.ts and cache/index.ts transitionally. 1.2 Remove import { FilesystemCacheStorage } from tool-cache.ts. Change ToolCache’s constructor: storage becomes required. Delete the cacheDir/getCacheDir fallback branch. Bump a TODO marker. 1.3 Remove DEFAULT_CACHE_DIR = join(homedir(), ...) from tool-cache.ts; move to cache/node.ts as a lazy getter (function defaultCacheDir() { return join(homedir(), ".galaxy", "tool_info_cache"); }) plus the constant form (fine if only evaluated in Node entry). 1.4 Create packages/core/src/cache/node.ts exporting: FilesystemCacheStorage, getCacheDir, DEFAULT_CACHE_DIR, CACHE_DIR_ENV_VAR, makeNodeToolCache(opts?). 1.5 Move loadWorkflowToolConfig into a new src/config-node.ts. Universal config.ts keeps WorkflowToolConfig, ToolSourceConfig, ToolCacheConfig, toolInfoOptionsFromConfig. 1.6 Create packages/core/src/node.ts that re-exports the Node-only surface (cache/node + config-node). 1.7 Rewrite packages/core/src/index.ts to export only the universal surface. Explicitly omit: FilesystemCacheStorage, getCacheDir, DEFAULT_CACHE_DIR, CACHE_DIR_ENV_VAR, loadWorkflowToolConfig. 1.8 Update package.json with the exports map above and "sideEffects": false. 1.9 Update tsconfig.json: include still ["src"]; keep "types": ["node"] (Node subpath needs it; universal entry happens to reference no node types). Consider splitting to tsconfig.base.json + tsconfig.node.json / tsconfig.browser.json later — not this phase.

Exit: pnpm -F @galaxy-tool-util/core build produces dist/index.js, dist/node.js, matching .d.ts. pnpm -F @galaxy-tool-util/core test green.

Phase 2 — Update in-repo consumers

2.1 packages/cli/** — imports FilesystemCacheStorage, ToolCache with default FS, and uses makeNodeToolCache. Change to import { ... } from "@galaxy-tool-util/core/node". Keep type-only imports (import type { ParsedTool, ToolCache }) on root. 2.2 packages/gxwf-web/** — same treatment for server-side code (bin/gxwf-web.ts, app.ts). loadWorkflowToolConfig moves to /node import. 2.3 packages/tool-cache-proxy/** — same. 2.4 packages/core/test/** — imports FS storage from /node or direct source path. Keep tests co-located with the code they test. 2.5 Run pnpm -r build && pnpm -r test && pnpm lint to confirm.

Phase 3 — Browser-safety verification

This is where best-practice tooling earns its keep.

3.1 publint (lints package.json — catches missing types, wrong conditions ordering, main vs exports drift).

pnpm add -D -w publint
pnpm -F @galaxy-tool-util/core exec publint

Add to CI and to packages/core/package.json as a lint:pkg script.

3.2 @arethetypeswrong/cli (verifies types resolve correctly under every module/condition combination — node10, node16, bundler, browser).

pnpm add -D -w @arethetypeswrong/cli
pnpm -F @galaxy-tool-util/core exec attw --pack .

Fails the build if /node subpath types don’t resolve or if "browser" condition is broken. Add to CI as check:types.

3.3 Browser-import smoke test (custom). Add packages/core/scripts/verify-browser-entry.mjs:

// Bundle dist/index.js with esbuild platform:"browser" and assert no node: imports survive.
import { build } from "esbuild";
const result = await build({
  entryPoints: ["dist/index.js"],
  bundle: true, format: "esm", platform: "browser",
  write: false, metafile: true, logLevel: "silent",
});
const nodeRefs = Object.keys(result.metafile.inputs)
  .filter((k) => /^node:|(^|\/)node_modules\/(fs|path|os|crypto|url)(\/|$)/.test(k));
if (nodeRefs.length) { console.error("Node builtins leaked:", nodeRefs); process.exit(1); }

Wire as pnpm -F @galaxy-tool-util/core check:browser. Runs in < 1 s. This is the single most valuable test — it will catch any future regression the moment a top-level node:* import sneaks back in.

3.4 Vitest browser mode (optional, higher ROI later). Vitest 4.1 supports test.browser with Playwright. Add packages/core/vitest.browser.config.ts:

import { defineProject } from "vitest/config";
export default defineProject({
  test: {
    include: ["test/**/*.browser.test.ts"],
    browser: { enabled: true, provider: "playwright", instances: [{ browser: "chromium" }] },
  },
});

Move/fork cache.test.ts into a cache.browser.test.ts that exercises IndexedDBCacheStorage against real IndexedDB (via fake-indexeddb not needed — real browser). Script: test:browser. Not required for the first cut; schedule as a follow-up if IndexedDB bugs appear in the gxwf-ui Monaco integration.

3.5 ESLint guard — ban node:* in universal sources. Add to eslint.config.js:

{
  files: ["packages/core/src/**/*.ts"],
  ignores: [
    "packages/core/src/node.ts",
    "packages/core/src/config-node.ts",
    "packages/core/src/cache/node.ts",
    "packages/core/src/cache/storage/filesystem.ts",
  ],
  rules: {
    "no-restricted-imports": ["error", {
      patterns: [{ group: ["node:*", "fs", "fs/promises", "os", "path", "child_process"],
                   message: "Universal core entry must stay browser-safe. Put Node code under src/**/node.ts or src/cache/node.ts." }],
    }],
  },
},

Cheap, static, and catches mistakes at the source file rather than at bundle time.

3.6 knip (optional). Once the dust settles, run knip to detect unused exports from the new split — easy to miss a dead re-export after refactoring. Don’t block on it.

Phase 4 — Downstream: remove shims in galaxy-workflows-vscode

4.1 Bump server/packages/server-common’s dep on @galaxy-tool-util/core (currently a local file: tarball) to the new version. 4.2 Change browser-side imports in server-common/src/providers/toolRegistry.ts (and wherever else appropriate) from @galaxy-tool-util/core@galaxy-tool-util/core (universal) plus any Node-only usages explicitly from @galaxy-tool-util/core/node. In practice the server-common browser code only uses IndexedDBCacheStorage, ToolCache (with injected storage), ToolInfoService, cacheKey, types — all universal. 4.3 Node-side entries that do want FilesystemCacheStorage (only if any remain) import from /node. Today the VS Code extension’s Node server also uses FilesystemCacheStorage as a default — flip to /node import there. 4.4 Delete server/browser-shims/. 4.5 In both server/gx-workflow-ls-*/tsup.config.ts:

Phase 5 — Release + docs

5.1 Changeset: @galaxy-tool-util/core minor bump. Changelog entry lists moved symbols. 5.2 packages/core/README.md — short “Usage” section distinguishing browser vs Node imports, with a one-line example for each. 5.3 docs/ — update any architecture notes that assume a single entry. 5.4 Publish to npm. Downstream (galaxy-workflows-vscode) picks up via a real version pin instead of the file: tarball dance.


Best practices checklist (what every browser-safe library should ship with)

PracticeToolIn plan?
Subpath exports separating Node vs browserpackage.json exports map✅ Phase 1
"browser" conditionpackage.json exports✅ Phase 1
"sideEffects": false for tree-shakingpackage.json✅ Phase 1
Validate exports mappublint✅ Phase 3.1
Validate types resolve under all conditions@arethetypeswrong/cli✅ Phase 3.2
Runtime check: bundle browser entry, detect leaksesbuild metafile script✅ Phase 3.3
Unit tests against real browser APIsVitest browser mode + PlaywrightPlanned (3.4, optional)
Lint-level guard against node:* in universal sourcesESLint no-restricted-imports✅ Phase 3.5
Detect dead exports post-refactorknipPlanned (3.6, optional)
Avoid top-level side-effects that touch Nodemanual review + ESLint; made enforceable by the ban on node:* imports
@types/node as devDep onlyalready the case

Testing subsets specific to the browser entry

All three belong in CI; the first two are cheap enough to run on every commit.


Rollout order

  1. Land Phases 1–3 in galaxy-tool-util-ts on a branch. Open PR.
  2. Once merged and published (or via a changeset-tagged prerelease), update galaxy-workflows-vscode to consume the new version and execute Phase 4.
  3. Delete server/browser-shims/ and the resolve plugin in a single commit titled revert: browser shims — upstream fix landed in @galaxy-tool-util/core@x.y.z. Link the upstream PR in the commit body.

Unresolved questions