Skip to content

feat(npm): expose types and runtime exports from the main shim package#343

Open
ayu-exorcist wants to merge 1 commit into
colbymchenry:mainfrom
ayu-exorcist:main
Open

feat(npm): expose types and runtime exports from the main shim package#343
ayu-exorcist wants to merge 1 commit into
colbymchenry:mainfrom
ayu-exorcist:main

Conversation

@ayu-exorcist
Copy link
Copy Markdown

feat(npm): expose types and runtime exports from the main shim package

Closes #342

Summary

This PR makes @colbymichenry/codegraph importable as a library — for both types and runtime — on every supported platform, without requiring consumers to hard-code a platform-specific package path.

Before (only works on Windows x64):

import type { CodeGraph, Node } from "@colbymchenry/codegraph-win32-x64/lib/dist/index";
const entry = require.resolve("@colbymchenry/codegraph-darwin-arm64/lib/dist/index.js");
const mod = await import(entry);

After (works everywhere):

import { CodeGraph, Node } from "@colbymchenry/codegraph";

Changes

The only file modified is scripts/pack-npm.sh (the release script that assembles published npm packages from platform bundles). No source code changes — this is purely a packaging fix.

1. Platform packages now self-describe

Each @colbymchenry/codegraph-<target> now declares:

{
  "main": "lib/dist/index.js",
  "types": "lib/dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./lib/dist/index.d.ts",
      "default": "./lib/dist/index.js"
    }
  }
}

This allows deep-path-free imports from the platform package directly:

import { CodeGraph } from "@colbymchenry/codegraph-darwin-arm64";

2. Main shim now ships type declarations

During pack-npm.sh, all .d.ts and .d.ts.map files from the first platform bundle are copied into main/lib/dist/. Because every platform bundle is compiled from the same TypeScript source, the declarations are identical across platforms.

3. Main shim now ships a runtime proxy

A new index.js is generated in the main shim:

const target = process.platform + '-' + process.arch;
try {
  module.exports = require('@colbymchenry/codegraph-' + target);
} catch (err) {
  // throws a descriptive error with install hints
}

This re-exports the entire platform package API through the canonical package name.

4. Main shim package.json now declares proper entry points

{
  "main": "index.js",
  "types": "lib/dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./lib/dist/index.d.ts",
      "require": "./index.js",
      "default": "./index.js"
    }
  },
  "files": ["index.js", "npm-shim.js", "README.md", "lib"]
}

Design Decisions

We evaluated several approaches for both type export and runtime export. Below is the full decision matrix.


Type Export: How do consumers get import type { ... } from "@colbymchenry/codegraph"?

Option A — Copy .d.ts from a platform bundle into the main shim (chosen)

How: pack-npm.sh copies lib/dist/*.d.ts from the first extracted bundle into main/lib/dist/.

Pros:

  • Zero maintenance cost — types are generated from the same tsc build that produces the bundle.
  • Identical across all platforms (same TS source → same .d.ts).
  • Consumers get the exact public API surface without any manual declaration files.
  • Works immediately with tsc, ts-node, Vite, Next.js, etc.

Cons:

  • Relies on the assumption that all platform bundles emit identical declarations (true today because they share the same src/ and tsconfig.json).
  • If platform-specific #ifdef-style code were ever introduced, the copied .d.ts could drift.

Option B — Make each platform package declare its own types

How: Add "types": "lib/dist/index.d.ts" to each @colbymchenry/codegraph-<target> package.json.

Pros:

  • Correct per-package metadata; consumers could import directly from the platform package.
  • No file copying needed.

Cons:

  • Does not solve the core problem: consumers still need to know the platform-specific package name to import from it.
  • The main shim remains type-less.

Verdict: We did this too (it's part of this PR), but it only solves half the problem. It is a prerequisite, not a replacement, for Option A.

Option C — Hand-write a separate .d.ts file in the repo

How: Maintain src/index.d.ts (or similar) by hand, and ship it in the main shim.

Pros:

  • Fully decoupled from bundle build output.
  • Can be curated to expose only the "public" API.

Cons:

  • High maintenance burden — every new export, renamed parameter, or added method requires a manual .d.ts update.
  • Guaranteed to drift out of sync with the source over time.
  • CodeGraph's API surface is large (CodeGraph class + ~20 interfaces + re-exports from submodules).

Verdict: Rejected — the maintenance cost outweighs the benefit for a package whose entire API is effectively public.

Option D — Use TypeScript typesVersions or peerDependencies trickery

How: Declare @colbymchenry/codegraph-darwin-arm64 (or similar) as a peerDependency or use typesVersions to redirect resolution.

Pros:

  • No file copying.

Cons:

  • typesVersions is deprecated-ish and poorly supported by modern tooling (Vite, esbuild, pnpm).
  • peerDependencies would force consumers to install a platform package explicitly, defeating the purpose of optionalDependencies.
  • Complex and fragile.

Verdict: Rejected — over-engineered for this use case.


Runtime Export: How do consumers get import { CodeGraph } from "@colbymchenry/codegraph"?

Option A — CJS proxy (index.js + require) (chosen)

How: Generate a small CommonJS file that requires the matching platform package and re-exports it via module.exports.

Pros:

  • Works perfectly in Node.js (the primary runtime for CodeGraph consumers: Claude Code, Pi, Cursor, VS Code extensions).
  • Synchronous — no await needed for the initial import.
  • Simple, predictable, zero bundler issues in Node contexts.
  • The exports map with require and default conditions covers both require() and dynamic import().

Cons:

  • Pure ESM bundlers (Vite without @rollup/plugin-commonjs, Rollup, some Deno configs) may struggle with CJS re-exports unless they use the default condition.
  • Does not provide true named exports in pure ESM contexts (re-exports from module.exports are interpreted as a default export by some tools).

Verdict: Chosen — CodeGraph's consumer base is 100% Node.js-based today (CLI tools, MCP servers, Pi extensions). The simplicity and zero-overhead trade-off is correct for this audience.

Option B — ESM + CJS dual proxy

How: Generate both index.js (CJS) and index.mjs (ESM) that each delegate to the platform package.

// index.mjs
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const mod = require('@colbymchenry/codegraph-' + process.platform + '-' + process.arch);
export const CodeGraph = mod.CodeGraph;
export const Node = mod.Node;
// ... enumerate every named export

Pros:

  • True ESM native support.
  • Vite, Rollup, Deno, and Cloudflare Workers all handle it perfectly.

Cons:

  • Must enumerate every named export in index.mjs (or use a build step to generate it).
  • If the platform package adds a new export, index.mjs must be regenerated or it will be missing.
  • Adds complexity to the release pipeline for a benefit that currently has no consumers.

Verdict: Deferred — can be added later without breaking changes by expanding the exports map:

"exports": {
  ".": {
    "types": "./lib/dist/index.d.ts",
    "import": "./index.mjs",
    "require": "./index.js",
    "default": "./index.js"
  }
}

Option C — No proxy; consumers dynamically import the platform package

How: Do nothing in the main shim. Consumers write:

const platform = `${process.platform}-${process.arch}`;
const { CodeGraph } = await import(`@colbymchenry/codegraph-${platform}`);

Pros:

  • Zero packaging complexity.
  • Works in both CJS and ESM.

Cons:

  • Every consumer must re-implement the platform resolution logic.
  • Template literal imports (await import(...)) are not statically analyzable by TypeScript or bundlers — types and tree-shaking break.
  • Defeats the purpose of having a canonical package name.

Verdict: Rejected — the whole point of this PR is to let consumers use the simple, canonical import.

Option D — Conditional exports pointing to platform packages

How: Use Node.js conditional exports with custom conditions per platform:

"exports": {
  ".": {
    "darwin-arm64": "./node_modules/@colbymchenry/codegraph-darwin-arm64/lib/dist/index.js",
    ...
  }
}

Pros:

  • No proxy file needed.

Cons:

  • Node.js does not support platform-specific conditional export keys. There is no built-in "darwin" or "arm64" export condition.
  • Would require a custom loader or environment variable, which is more invasive than a proxy.
  • The node_modules path is not guaranteed to be flat (pnpm, yarn PnP, etc.).

Verdict: Rejected — Node.js's exports conditions are not designed for this.


Why this specific combination?

Concern Our choice Rationale
Type resolution Copy .d.ts from bundle Zero maintenance, identical across platforms, works with all TS tooling
Runtime import CJS proxy (index.js) Node.js is the only target runtime; synchronous, simple, zero overhead
ESM interop exports.default condition Covers dynamic import() in ESM projects; true ESM proxy can be added later
Platform isolation Keep optionalDependencies npm automatically installs only the matching platform; no change to install model
Backward compat Shim still ships npm-shim.js Existing CLI usage (npx codegraph) is completely unaffected

Backward Compatibility

  • CLI usage (npx @colbymchenry/codegraph, npm i -g @colbymchenry/codegraph): Unchanged. The bin field still points to npm-shim.js.
  • Existing deep-path imports (@colbymchenry/codegraph-win32-x64/lib/dist/index): Still work. Platform packages still contain the same files at the same paths.
  • Package size: The main shim grows by ~190 KB of .d.ts files (only declarations, no JS runtime). This is negligible compared to the ~150 MB platform bundles.

Testing

The release script (scripts/pack-npm.sh) was validated with a minimal mock bundle:

# 1. Create a fake darwin-arm64 bundle
mkdir -p release/codegraph-darwin-arm64/lib/dist
echo 'exports.CodeGraph = class CodeGraph {};' > release/.../lib/dist/index.js
echo 'export class CodeGraph {}' > release/.../lib/dist/index.d.ts
tar -czf release/codegraph-darwin-arm64.tar.gz -C release codegraph-darwin-arm64

# 2. Run pack-npm.sh
bash scripts/pack-npm.sh 0.9.4-test

# 3. Verify structure
find release/npm -type f | sort
# → release/npm/main/index.js
# → release/npm/main/lib/dist/index.d.ts
# → release/npm/main/package.json
# → release/npm/codegraph-darwin-arm64/...

# 4. Verify runtime proxy works
cd release/npm/main
node -e "const cg = require('.'); console.log(typeof cg.CodeGraph);"
# → function

The actual release pipeline (.github/workflows/release.yml) calls scripts/pack-npm.sh unchanged, so the fix will be picked up automatically on the next release.

Copilot AI review requested due to automatic review settings May 23, 2026 05:33
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Updates the npm packaging script to expose runtime entrypoints and TypeScript types for both platform-specific bundles and the main “shim” package, including a generated proxy module that forwards loading to the correct optional dependency.

Changes:

  • Add main/types/exports fields to generated package.json files (platform bundles + main shim).
  • Copy .d.ts artifacts into the main shim so types can be imported from @scope/codegraph.
  • Generate a runtime index.js proxy that requires the platform-specific package based on process.platform/process.arch.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread scripts/pack-npm.sh Outdated
Comment thread scripts/pack-npm.sh
Comment thread scripts/pack-npm.sh
Comment thread scripts/pack-npm.sh Outdated
Comment thread scripts/pack-npm.sh Outdated
Before this change, consumers had to hard-code a platform-specific
package path to get types or runtime imports:

  import type { CodeGraph } from "@colbymchenry/codegraph-win32-x64/lib/dist/index"

This broke on every platform except Windows because the win32 package
is not installed as an optionalDependency on other OSs.

What changed:

1. Platform packages now declare main/types/exports so they can be
   imported directly without deep paths.

2. The main shim package now ships:
   - lib/dist/*.d.ts  — type declarations copied from the first
     platform bundle (all platforms emit identical .d.ts from the
     same TS source).
   - index.js         — a CJS runtime proxy that resolves the
     matching platform package at require()-time and re-exports
     everything.

3. The main shim package.json now declares:
   - "main": "index.js"
   - "types": "lib/dist/index.d.ts"
   - "exports" with types/require/default conditions

Consumers can now write:

  import { CodeGraph, Node } from "@colbymchenry/codegraph";

...and it works on every supported platform (darwin, linux, win32,
arm64, x64) without any tsconfig paths workaround.
@ayu-exorcist
Copy link
Copy Markdown
Author

@copilot-pull-request-reviewer All 5 points addressed in the amended commit. Summary:

  1. Scope hard-coding — Proxy now reads "./package.json" and derives scope from "pkg.name.split('/')[0]". Works for any scope/fork.

  2. Error masking — "catch (err)" now checks "err.code !== 'MODULE_NOT_FOUND'" and rethrows the original error immediately. Only module-not-found is translated to the friendly message.

  3. Missing types guard — After copying .d.ts files, the script asserts "[ -f "$NPM/main/lib/dist/index.d.ts" ]" and exits 1 with an error message if the types entrypoint is absent. Prevents publishing a broken shim.

  4. ESM comment accuracy — Updated the comment to clarify that require()/dynamic import() work under Node.js, and that named imports function via Node.js native ESM↔CJS interop (verified) but may need bundler-specific config in Vite/Rollup.

  5. find filename safety — Switched to "find ... -print0 | while IFS= read -r -d '' f" for NUL-delimited filename handling.

All changes are in "scripts/pack-npm.sh" only (amended commit "c338bfa").

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@colbymchenry/codegraph cannot be imported as a library on any platform

2 participants