Dual-Mode Distribution Best Practices
Overview
This document provides comprehensive guidance on distributing chatline as both a CLI tool and a library package. It synthesizes research from official TypeScript and Node.js documentation to ensure best practices for dual-mode packages.
Research Foundation
- TypeScript Documentation: Explored 60+ package.json configuration examples from TypeScript's test baselines, covering conditional exports, ESM/CJS dual distributions, and types resolution
- Node.js Official Packages API: Complete Node.js v25.1.0 package.json specification including exports, bin, main, and conditional exports patterns
- Key Findings: Modern Node.js (v12+) with
exportsfield provides superior encapsulation and subpath control compared to legacymain-only configurations
Package Architecture
Dual Entry Points Strategy
The package provides two distinct entry points:
-
CLI Entry Point: Executable binary for command-line usage
- Field:
bin - Target:
./dist/cli.js - Usage:
npx /chatlineorchatline(when installed globally)
- Field:
-
Library Entry Point: Module exports for programmatic usage
- Fields:
main,exports,types - Target:
./dist/index.js(runtime),./dist/index.d.ts(types) - Usage:
import { loadConfig } from '@nathanvale/chatline'
- Fields:
Current Configuration Analysis
{
"bin": {
"chatline": "./dist/cli.js"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json"
},
"main": "./dist/index.js",
"name": "chatline",
"type": "module",
"types": "./dist/index.d.ts"
}
Status: ✅ Already optimal for dual-mode distribution
Best Practices from Research
1. Module Type Declaration
{
"type": "module"
}
Why: Declares all .js files as ES modules. This is essential for
Node.js v12+ to correctly interpret imports and prevent CommonJS/ESM conflicts.
Alternatives:
.mjsextensions force ESM (regardless oftypefield).cjsextensions force CommonJS (regardless oftypefield)
Our Choice: Use "type": "module" with .js extensions for simplicity and
consistency.
2. Exports Field (Modern Standard)
The exports field is the recommended approach for defining package entry
points in Node.js v12+.
Benefits
- Encapsulation: Prevents importing internal modules (e.g.,
require('pkg/internal/utils')) - Conditional Exports: Different entry points for
importvsrequire - Subpath Patterns: Control which subpaths are accessible
- Types Integration: Direct TypeScript declaration file mapping
Pattern: Main Entry Point
{
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}
Research Insight: The "types" condition should always be first in the
condition object (per TypeScript community conventions and Runtime Keys
proposal).
Pattern: Exposing package.json
{
"exports": {
".": "./dist/index.js",
"./package.json": "./package.json"
}
}
Why: Allows consumers to read package metadata (e.g., for version checks) without breaking encapsulation.
Pattern: Subpath Exports (Optional)
{
"exports": {
".": "./dist/index.js",
"./config": "./dist/config/index.js",
"./utils": "./dist/utils/index.js"
}
}
When to Use: For large packages where exposing organized subpaths improves tree-shaking and reduces bundle size. Not necessary for small/medium packages.
Pattern: Wildcard Subpath Exports (Advanced)
{
"exports": {
".": "./dist/index.js",
"./config/*": "./dist/config/*.js",
"./utils/*": "./dist/utils/*.js"
}
}
When to Use: For packages with many submodules. Allows
import { x } from 'pkg/config/loader.js' without enumerating every file.
Trade-offs: Less explicit API surface, harder to track breaking changes.
3. Main Field (Backward Compatibility)
{
"main": "./dist/index.js"
}
Why: Provides fallback for Node.js v10 and older tools that don't support
exports field.
Behavior: When both main and exports are present, modern Node.js
prefers exports, but older environments fall back to main.
Best Practice: Always include both main and exports pointing to the same
entry file for maximum compatibility.
4. Types Field
{
"types": "./dist/index.d.ts"
}
Why: Defines the TypeScript declaration entry point for the package.
Relationship with exports:
- If
exportsincludes a"types"condition, it takes precedence - If
exportsdoesn't specify"types", TypeScript falls back to the top-level"types"field
Best Practice: Include both:
- Top-level
"types"for legacy TypeScript versions "types"condition inexportsfor modern resolution
5. Bin Field (CLI Support)
{
"bin": {
"chatline": "./dist/cli.js"
}
}
Requirements:
- Executable Shebang: First line of
dist/cli.jsmust be#!/usr/bin/env node - File Permissions: Must be executable (
chmod +x dist/cli.js) or npm will handle this automatically during install - Separate from Library: CLI entry point should be distinct from library entry point
Multiple Binaries (if needed):
{
"bin": {
"chatline": "./dist/cli.js",
"imt": "./dist/cli.js"
}
}
6. Files Field (Distribution Control)
{
"files": ["dist/**", "README.md", "LICENSE", "CHANGELOG.md"]
}
Why: Controls which files are included when publishing to npm. Keeps package size minimal.
Note: package.json is always included automatically.
Conditional Exports Deep Dive
Condition Order (Critical)
Conditions are matched top-to-bottom, so order matters:
{
"exports": {
".": {
"default": "./dist/index.js", // 5. Fallback
"import": "./dist/index.mjs", // 3. ESM import
"node": "./dist/index.node.js", // 2. Node.js-specific
"require": "./dist/index.cjs", // 4. CommonJS require
"types": "./dist/index.d.ts" // 1. Types (TypeScript)
}
}
}
Best Practice Order (from TypeScript and Runtime Keys proposal):
"types"— TypeScript declaration files- Platform-specific (
"node","browser","deno", etc.) - Module format (
"import","require","module-sync") "default"— Always last, as universal fallback
Common Condition Use Cases
Import vs Require (Dual Module Support)
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
When to Use: When supporting both ESM (import) and CommonJS (require)
consumers.
Our Case: Not needed — we only distribute ESM (via "type": "module").
Node.js vs Browser
{
"exports": {
".": {
"browser": "./dist/index.browser.js",
"default": "./dist/index.js",
"node": "./dist/index.node.js"
}
}
}
When to Use: When providing different implementations for server vs browser environments.
Our Case: Not needed — CLI tool is Node.js-only.
Development vs Production
{
"exports": {
".": {
"default": "./dist/index.js",
"development": "./dist/index.dev.js",
"production": "./dist/index.prod.js"
}
}
}
When to Use: When providing debug-enabled builds for development.
Activation: node --conditions=development index.js
Our Case: Not needed for MVP, but useful for future enhanced logging.
Subpath Imports (Internal Package Aliases)
Current Configuration
{
"imports": {
"#enrich/*": "./src/enrich/*",
"#ingest/*": "./src/ingest/*",
"#normalize/*": "./src/normalize/*",
"#render/*": "./src/render/*",
"#schema/*": "./src/schema/*"
}
}
Purpose: Creates internal aliases for use within the package itself, not exposed to consumers.
Requirements:
- All imports must start with
# - Used in source files:
import { Message } from '#schema/message.js'
Benefits:
- Shorter import paths within the codebase
- Easy refactoring (change mapping in one place)
- No impact on consumers (not exported)
Note: These are not accessible to library consumers — they're internal-only.
Self-Referencing (Package Name Imports)
Feature: Importing from Own Package
When exports is defined, modules inside the package can import using the
package name:
// Inside src/some-module.ts
import { loadConfig } from '@nathanvale/chatline'
Instead of relative imports:
import { loadConfig } from '../config/loader.js'
Requirements:
- Must have
"name"field in package.json - Must have
"exports"field defined - Only works for paths explicitly listed in
exports
When to Use:
- Testing internal APIs from the consumer's perspective
- Ensuring consistency between internal and external imports
Our Case: Useful for tests, but relative imports are fine for source code.
TypeScript Configuration for Dual Distribution
tsconfig.json Settings
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src"
}
}
Key Settings:
module: "NodeNext": Matches Node.js ES module resolutionmoduleResolution: "NodeNext": Enablesexportsfield supportdeclaration: true: Generates.d.tsfilesdeclarationMap: true: Enables "Go to Definition" in consumers' editors
Source File Extensions
Current: .ts files (source) → .js files (compiled)
Recommendations:
- Keep
.tsfor source files (simplicity) - Use
.jsextensions in imports:import './foo.js'(not'./foo')
Why: TypeScript requires explicit extensions in ESM mode when using
moduleResolution: NodeNext.
API Design Best Practices for Dual Distribution
1. Clear Entry Point (src/index.ts)
/**
* Public API for chatline library
*
* This package can be used both as:
* 1. CLI tool: `npx /chatline --help`
* 2. Library: `import { loadConfig } from '@nathanvale/chatline'`
*/
// Export only public APIs
export { loadConfig, generateConfigContent } from './config/index.js'
export type { Config } from './config/schema.js'
// Do NOT export internal utilities unless needed
Guidelines:
- Document dual-use nature at the top
- Export only stable public APIs
- Use JSDoc comments for each export
- Group exports by feature/module
2. Separate CLI and Library Code
Pattern:
src/
cli.ts ← CLI entry point (Commander, arg parsing)
index.ts ← Library entry point (public API exports)
config/ ← Shared logic (used by both CLI and library)
utils/ ← Shared utilities
Benefits:
- CLI-specific dependencies (e.g., Commander) don't pollute library consumers
- Clear separation of concerns
- Easier to maintain and test independently
3. Avoid CLI-Only Code in Library Exports
Bad:
// src/index.ts (BAD)
export { parseCliArgs } from './cli.js' // ❌ CLI-specific
Good:
// src/index.ts (GOOD)
export { loadConfig } from './config/loader.js' // ✅ Useful in library
Reason: Library consumers don't need CLI argument parsing logic.
Testing Dual Distribution
1. Test CLI Usage
# Local development
bun src/cli.ts --help
# After build
node dist/cli.js --help
# As installed package
npx /chatline --help
2. Test Library Import
// test-library-import.ts
import { loadConfig } from '@nathanvale/chatline'
const config = loadConfig('path/to/config.yaml')
console.log('Config loaded:', config)
Run:
bun test-library-import.ts
3. Test Package.json Exports
# Ensure only exported paths work
node -e "import('@nathanvale/chatline').then(console.log)" # ✅ Should work
node -e "import('chatline/config').then(console.log)" # ❌ Should fail (not exported)
node -e "import('chatline/package.json').then(console.log)" # ✅ Should work (explicitly exported)
Publishing Checklist
Pre-Publish Validation
-
Build Check:
bun run build
ls -lh dist/ # Verify cli.js and index.js exist -
Shebang Check:
head -n 1 dist/cli.js # Should be #!/usr/bin/env node -
TypeScript Types Check:
ls -lh dist/*.d.ts # Verify .d.ts files generated -
Files Check:
npm pack --dry-run # Preview what will be published -
Exports Validation:
node --input-type=module -e "import('@nathanvale/chatline').then(m => console.log(Object.keys(m)))"
Package.json Final Check
{
"bin": { "chatline": "./dist/cli.js" },
"engines": { "node": ">=22.20" },
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json"
},
"files": ["dist/**", "README.md", "LICENSE"],
"main": "./dist/index.js",
"name": "chatline",
"type": "module",
"types": "./dist/index.d.ts",
"version": "1.0.0"
}
Common Pitfalls and Solutions
Issue 1: CLI Not Executable After Install
Symptom: command not found: chatline after npm install -g
Solution:
- Verify
binfield points to correct file - Ensure
#!/usr/bin/env nodeis first line of CLI file - Check file permissions:
ls -l dist/cli.js(should showxflag)
Issue 2: Cannot Find Module When Importing
Symptom: ERR_MODULE_NOT_FOUND when importing from library
Solution:
- Verify
exportsfield includes".": "./dist/index.js" - Check TypeScript compiled files to
dist/ - Ensure
"type": "module"is set - Use
.jsextensions in import paths (even in TypeScript)
Issue 3: TypeScript Types Not Found
Symptom: Could not find a declaration file for module '@nathanvale/chatline'
Solution:
- Verify
"types": "./dist/index.d.ts"in package.json - Add
"types"condition toexportsfield - Check
dist/contains.d.tsfiles - Ensure
tsconfig.jsonhas"declaration": true
Issue 4: Internal Paths Exposed
Symptom: Consumers can import require('pkg/internal/secret.js')
Solution:
- Use
exportsfield to restrict paths - Do NOT include
"./internal/*"in exports - Test with:
node -e "import('chatline/internal/file.js')"- Should throw
ERR_PACKAGE_PATH_NOT_EXPORTED
- Should throw
Advanced Patterns (Future Considerations)
1. Subpath Exports for Large Packages
If the package grows significantly, consider exposing organized subpaths:
{
"exports": {
".": "./dist/index.js",
"./config": "./dist/config/index.js",
"./enrich": "./dist/enrich/index.js",
"./ingest": "./dist/ingest/index.js",
"./render": "./dist/render/index.js"
}
}
Benefits:
- Tree-shaking: Import only needed modules
- Clear API boundaries
- Easier versioning and deprecation
Trade-offs:
- More maintenance (must update for each new subpath)
- Potential breaking changes if internal structure changes
2. Conditional Exports for Minified Builds
{
"exports": {
".": {
"default": "./dist/index.js",
"development": "./dist/index.js",
"production": "./dist/index.min.js"
}
}
}
Activation: node --conditions=production index.js
3. Dual ESM/CJS Distribution
If CommonJS support becomes necessary:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"type": "module"
}
Requirements:
- Compile to both
.mjs(ESM) and.cjs(CommonJS) - Handle dual package hazards (see Node.js documentation)
- Significant complexity increase
Recommendation: Avoid unless there's strong demand for CJS support.
Summary
Current Status: ✅ Excellent
The package is already configured optimally for dual CLI + library distribution:
- ✅ Separate
binandmainentry points - ✅ Modern
exportsfield with types integration - ✅ Backward-compatible
mainfallback - ✅ ESM-only distribution (
"type": "module") - ✅ Internal package aliases via
imports - ✅ Proper
filesfield for distribution control
No Breaking Changes Needed
All current configurations align with best practices from TypeScript and Node.js official documentation.
Recommendations for src/index.ts
- Complete API Exports: Ensure all intended public functions/types are exported
- Clear Documentation: Add JSDoc comments for each major export
- Verify Import Paths: Fix any import errors (e.g., missing
.jsextensions)
Next Steps
- Fix import errors in
src/index.ts(detailed in next section) - Update README with dual-mode usage examples
- Test both CLI and library usage patterns
- Publish to npm with confidence 🚀
Appendix: Research Sources
TypeScript Documentation
- Source: microsoft/typescript repository test baselines
- Key Files:
nodeModulesDeclarationEmitWithPackageExportsconditionalExportsResolution*nodeModulesExports*
- Coverage: 60+ conditional exports examples, ESM/CJS dual patterns, types resolution strategies
Node.js Documentation
- Source: Node.js v25.1.0 official Packages API documentation
- URL: https://nodejs.org/api/packages.html
- Key Sections:
- Package entry points (main, exports, bin)
- Conditional exports and community conditions
- Subpath exports and subpath patterns
- Module resolution algorithms (CommonJS vs ESM)
- Self-referencing and package imports
Community Standards
- Runtime Keys Proposal (WinterCG): Defines standard condition keys for cross-runtime compatibility
- TypeScript Handbook: Module resolution strategies for package authors
- NPM Documentation: Publishing best practices and package.json field definitions
Appendix: Quick Reference Card
Minimal Dual-Mode Package
{
"bin": { "my-cli": "./dist/cli.js" },
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"main": "./dist/index.js",
"name": "my-package",
"type": "module",
"types": "./dist/index.d.ts",
"version": "1.0.0"
}
CLI Entry Point Template
#!/usr/bin/env node
import { Command } from 'commander'
import { doSomething } from './library-code.js'
const program = new Command()
program
.name('my-cli')
.description('CLI tool description')
.action(() => {
doSomething()
})
program.parse()
Library Entry Point Template
/**
* Public API for my-package
*
* Can be used as:
* - CLI: npx my-package
* - Library: import { doSomething } from 'my-package'
*/
export { doSomething } from './library-code.js'
export type { SomeType } from './types.js'
Document Version: 1.0
Last Updated: 2025-01-27
Author: GitHub Copilot + TypeScript/Node.js Official Documentation
Review Status: Ready for Implementation ✅