Skip to content

Commit 275e81f

Browse files
committed
Complete Node.js migration with custom ESM loader
- Created custom ESM loader (loader.mjs) to handle: - Asset files (.txt, .scm, .md, .wasm, .dylib, etc.) - bun: protocol imports (redirects to compat layers) - TypeScript files in node_modules (prevents CJS transformation) - Extended Bun compatibility layer (src/util/bun-compat.ts): - Added Bun.Glob (using minimatch) - Added Bun.which (executable finder) - Added Bun.stderr (stderr writer) - Added Bun.color (ANSI color codes) - Created bun:ffi stub (src/util/bun-ffi-compat.ts) - Provides minimal stubs to prevent import errors - Logs warnings when FFI functions are called - Created global Bun shim (global-bun-shim.mjs) - Injects Bun into global scope for third-party libraries - Converted text file imports to fs.readFileSync: - Fixed 20+ files that imported .txt files - Added necessary fs, path, and fileURLToPath imports - Fixed compatibility issues: - Replaced fs.exists with fs.access (deprecated API) - Fixed duplicate fs imports - Added Bun imports where needed - Created launcher scripts: - opencode-node.mjs (Unix/macOS) - opencode-node.bat (Windows CMD) - opencode-node.ps1 (Windows PowerShell) - Added comprehensive documentation (NODE_MIGRATION.md) Successfully tested on macOS with Node.js v25.2.1
1 parent 1278c54 commit 275e81f

39 files changed

Lines changed: 22035 additions & 38 deletions

fix-txt-imports.mjs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { fileURLToPath } from 'url';
4+
5+
const __filename = fileURLToPath(import.meta.url);
6+
const __dirname = path.dirname(__filename);
7+
8+
// Find all .ts files in packages/opencode/src
9+
function findTsFiles(dir, files = []) {
10+
const entries = fs.readdirSync(dir, { withFileTypes: true });
11+
for (const entry of entries) {
12+
const fullPath = path.join(dir, entry.name);
13+
if (entry.isDirectory() && entry.name !== 'node_modules') {
14+
findTsFiles(fullPath, files);
15+
} else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
16+
files.push(fullPath);
17+
}
18+
}
19+
return files;
20+
}
21+
22+
const files = findTsFiles(path.join(__dirname, 'packages/opencode/src'));
23+
24+
for (const file of files) {
25+
let content = fs.readFileSync(file, 'utf-8');
26+
const originalContent = content;
27+
28+
// Match: import SOMETHING from "./path/to/file.txt"
29+
const importRegex = /import\s+(\w+)\s+from\s+["']([^"']+\.txt)["']/g;
30+
31+
const matches = [...content.matchAll(importRegex)];
32+
if (matches.length === 0) continue;
33+
34+
console.log(`Processing ${file}...`);
35+
36+
// Check if already has needed imports
37+
const hasFs = /import.*fs.*from\s+["']fs["']/.test(content);
38+
const hasPath = /import.*path.*from\s+["']path["']/.test(content);
39+
const hasFileURLToPath = /import.*fileURLToPath.*from\s+["']url["']/.test(content);
40+
41+
// Remove the txt imports
42+
content = content.replace(importRegex, '');
43+
44+
// Add necessary imports if not present
45+
let importsToAdd = [];
46+
if (!hasFs) importsToAdd.push(`import fs from "fs"`);
47+
if (!hasPath) importsToAdd.push(`import path from "path"`);
48+
if (!hasFileURLToPath) importsToAdd.push(`import { fileURLToPath } from "url"`);
49+
50+
if (importsToAdd.length > 0) {
51+
// Find the first import statement
52+
const firstImportMatch = content.match(/^import\s+/m);
53+
if (firstImportMatch) {
54+
const insertPos = firstImportMatch.index;
55+
content = content.slice(0, insertPos) + importsToAdd.join('\n') + '\n' + content.slice(insertPos);
56+
}
57+
}
58+
59+
// Add __dirname setup if not present
60+
if (!/__dirname/.test(content)) {
61+
const firstNonImportLine = content.search(/\n\n[^import]/);
62+
if (firstNonImportLine !== -1) {
63+
const setup = `\nconst __filename = fileURLToPath(import.meta.url)\nconst __dirname = path.dirname(__filename)\n`;
64+
content = content.slice(0, firstNonImportLine) + setup + content.slice(firstNonImportLine);
65+
}
66+
}
67+
68+
// Add const declarations for each txt file
69+
for (const match of matches) {
70+
const [, varName, txtPath] = match;
71+
const constDecl = `const ${varName} = fs.readFileSync(path.join(__dirname, "${txtPath}"), "utf-8")\n`;
72+
73+
// Find a good place to insert (after imports and __dirname setup)
74+
const lastImport = content.lastIndexOf('import ');
75+
if (lastImport !== -1) {
76+
const afterLastImport = content.indexOf('\n', lastImport) + 1;
77+
const afterDirname = content.indexOf('const __dirname', afterLastImport);
78+
if (afterDirname !== -1) {
79+
const insertPos = content.indexOf('\n', afterDirname) + 1;
80+
content = content.slice(0, insertPos) + constDecl + content.slice(insertPos);
81+
} else {
82+
content = content.slice(0, afterLastImport) + '\n' + constDecl + content.slice(afterLastImport);
83+
}
84+
}
85+
}
86+
87+
if (content !== originalContent) {
88+
fs.writeFileSync(file, content);
89+
console.log(`Updated ${file}`);
90+
}
91+
}
92+
93+
console.log('Done!');

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"husky": "9.1.7",
2626
"prettier": "3.6.2",
2727
"sst": "3.17.23",
28+
"ts-node": "^10.9.2",
2829
"turbo": "2.5.6"
2930
},
3031
"dependencies": {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# OpenCode Node.js Migration
2+
3+
This directory contains the Node.js-compatible version of OpenCode, migrated from Bun.
4+
5+
## Running OpenCode with Node.js
6+
7+
### Prerequisites
8+
9+
- Node.js v22+ (tested with v25.2.1)
10+
- pnpm (for package management)
11+
12+
### Installation
13+
14+
```bash
15+
cd /Users/matt/repo/opencode
16+
pnpm install
17+
```
18+
19+
### Running
20+
21+
**Unix/Linux/macOS:**
22+
```bash
23+
cd packages/opencode
24+
./opencode-node.mjs [arguments]
25+
```
26+
27+
**Windows (PowerShell):**
28+
```powershell
29+
cd packages\opencode
30+
.\opencode-node.ps1 [arguments]
31+
```
32+
33+
**Windows (Command Prompt):**
34+
```cmd
35+
cd packages\opencode
36+
opencode-node.bat [arguments]
37+
```
38+
39+
**Direct Node.js command (all platforms):**
40+
```bash
41+
node --loader ./loader.mjs --conditions=browser --import ./global-bun-shim.mjs --import tsx/esm ./src/index.ts [arguments]
42+
```
43+
44+
### Examples
45+
46+
```bash
47+
# Show help
48+
./opencode-node.mjs --help
49+
50+
# Show version
51+
./opencode-node.mjs --version
52+
53+
# Run OpenCode
54+
./opencode-node.mjs
55+
```
56+
57+
## Migration Details
58+
59+
### Key Changes
60+
61+
1. **Bun Compatibility Layer** (`src/util/bun-compat.ts`)
62+
- Implements Bun APIs using Node.js equivalents
63+
- Provides: `Bun.$`, `Bun.file`, `Bun.write`, `Bun.spawn`, `Bun.Glob`, `Bun.which`, `Bun.stderr`, `Bun.color`
64+
65+
2. **Custom ESM Loader** (`loader.mjs`)
66+
- Handles asset file imports (.txt, .scm, .md, .wasm, etc.)
67+
- Resolves `bun:` protocol imports to compatibility layers
68+
- Prevents tsx from transforming TypeScript files in node_modules to CJS
69+
70+
3. **Global Bun Shim** (`global-bun-shim.mjs`)
71+
- Injects Bun compatibility layer into global scope
72+
- Allows third-party libraries expecting `Bun` to work
73+
74+
4. **Text File Import Conversion**
75+
- Converted all `.txt` file imports to use `fs.readFileSync()`
76+
- Added necessary imports (`fs`, `path`, `fileURLToPath`)
77+
78+
5. **Package Updates**
79+
- Changed package manager from `bun` to `pnpm`
80+
- Replaced `bun-pty` with `node-pty`
81+
- Updated tsconfig to use `@tsconfig/node22`
82+
- Added path mapping for `bun``./src/util/bun-compat.ts`
83+
84+
### Known Limitations
85+
86+
1. **bun:ffi Stub**: The `bun:ffi` module is stubbed out, so native FFI functionality from `@opentui/core` may be limited
87+
2. **Experimental Loader Warning**: Node.js shows a warning about the experimental loader API (can be suppressed with `NODE_NO_WARNINGS=1`)
88+
3. **Performance**: May be slightly slower than Bun due to different runtime optimizations
89+
90+
### Files Modified
91+
92+
**Core Compatibility:**
93+
- `src/util/bun-compat.ts` - Bun API compatibility layer
94+
- `src/util/bun-ffi-compat.ts` - Bun FFI stub
95+
- `loader.mjs` - Custom ESM loader
96+
- `global-bun-shim.mjs` - Global Bun injection
97+
98+
**Import Fixes:**
99+
- `src/provider/models-macro.ts` - Removed macro, added Bun compat import
100+
- `src/provider/models.ts` - Removed macro import, added Bun compat
101+
- `src/util/filesystem.ts` - Added Bun import, fixed `fs.exists`
102+
- `src/util/log.ts` - Added Bun import
103+
- `src/file/ignore.ts` - Added Bun import
104+
- `src/file/ripgrep.ts` - Added Bun import
105+
- `src/cli/ui.ts` - Added Bun import
106+
- `src/session/prompt.ts` - Fixed duplicate fs imports
107+
- 20+ files - Converted .txt imports to fs.readFileSync
108+
109+
**Configuration:**
110+
- `package.json` - Updated package manager and dependencies
111+
- `tsconfig.json` - Added bun path mapping
112+
- `pnpm-workspace.yaml` - Created for pnpm
113+
- `packages/sdk/js/package.json` - Added src to files array
114+
115+
**Launchers:**
116+
- `opencode-node.mjs` - Unix/macOS launcher
117+
- `opencode-node.bat` - Windows batch launcher
118+
- `opencode-node.ps1` - Windows PowerShell launcher
119+
120+
## Testing
121+
122+
Tested on:
123+
- ✅ macOS (darwin-arm64) with Node.js v25.2.1
124+
- ⏳ Windows Server 2022 (pending)
125+
126+
## Future Work
127+
128+
- Package as standalone executable using `pkg`, `nexe`, or `caxa`
129+
- Implement full `bun:ffi` support using node-ffi-napi
130+
- Add CI/CD for automated testing
131+
- Create installers for Windows, macOS, Linux
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Global Bun shim for Node.js
3+
* This makes Bun APIs available globally for libraries that expect them
4+
*/
5+
import { Bun } from './src/util/bun-compat.ts'
6+
7+
// Inject Bun into global scope
8+
globalThis.Bun = Bun

packages/opencode/loader.mjs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Custom Node.js loader for asset files
3+
* Handles .txt, .scm, .md, WASM, and other text-based asset files
4+
*/
5+
import { readFile } from 'fs/promises';
6+
import { fileURLToPath } from 'url';
7+
8+
const TEXT_ASSET_EXTENSIONS = [
9+
'.txt',
10+
'.scm',
11+
'.md',
12+
'.css',
13+
'.html',
14+
'.xml',
15+
'.json',
16+
'.yaml',
17+
'.yml',
18+
'.toml',
19+
];
20+
21+
const BINARY_ASSET_EXTENSIONS = [
22+
'.wasm',
23+
'.dylib',
24+
'.so',
25+
'.dll',
26+
'.node',
27+
];
28+
29+
export async function resolve(specifier, context, nextResolve) {
30+
// Handle bun: protocol imports by redirecting to our compat layer
31+
if (specifier.startsWith('bun:')) {
32+
// Map bun: imports to specific modules
33+
const bunModule = specifier.replace('bun:', '');
34+
35+
// Handle specific bun modules
36+
if (bunModule === 'ffi') {
37+
return {
38+
url: new URL('./src/util/bun-ffi-compat.ts', import.meta.url).href,
39+
shortCircuit: true,
40+
};
41+
}
42+
43+
// For other bun: imports, redirect to our general compat layer
44+
return {
45+
url: new URL('./src/util/bun-compat.ts', import.meta.url).href,
46+
shortCircuit: true,
47+
};
48+
}
49+
50+
// Let the default resolver handle the resolution
51+
return nextResolve(specifier, context);
52+
}
53+
54+
export async function load(url, context, nextLoad) {
55+
// Only handle file:// URLs
56+
if (!url.startsWith('file://')) {
57+
return nextLoad(url, context);
58+
}
59+
60+
const filePath = fileURLToPath(url);
61+
62+
// Skip transformation of TypeScript files in node_modules
63+
// This prevents tsx from trying to transform native modules with top-level await
64+
if (filePath.includes('node_modules') && filePath.endsWith('.ts')) {
65+
// Let Node.js handle it - it will likely error, but that's better than tsx transforming it to CJS
66+
// Actually, we should read and return as ESM module
67+
try {
68+
const content = await readFile(filePath, 'utf-8');
69+
return {
70+
format: 'module',
71+
source: content,
72+
shortCircuit: true,
73+
};
74+
} catch (error) {
75+
console.error(`Failed to load TS file ${filePath}:`, error);
76+
throw error;
77+
}
78+
}
79+
80+
// Check if this is a text asset file
81+
const isTextAsset = TEXT_ASSET_EXTENSIONS.some(ext => filePath.endsWith(ext));
82+
83+
if (isTextAsset) {
84+
try {
85+
const content = await readFile(filePath, 'utf-8');
86+
87+
// Return the content as a default export
88+
return {
89+
format: 'module',
90+
source: `export default ${JSON.stringify(content)};`,
91+
shortCircuit: true,
92+
};
93+
} catch (error) {
94+
console.error(`Failed to load text asset file ${filePath}:`, error);
95+
throw error;
96+
}
97+
}
98+
99+
// Check if this is a WASM file
100+
const isWasm = BINARY_ASSET_EXTENSIONS.some(ext => filePath.endsWith(ext));
101+
102+
if (isWasm) {
103+
try {
104+
// For WASM files with type: "file", just return the file path
105+
// The code expects a file path, not the actual content
106+
return {
107+
format: 'module',
108+
source: `export default ${JSON.stringify(filePath)};`,
109+
shortCircuit: true,
110+
};
111+
} catch (error) {
112+
console.error(`Failed to load WASM file ${filePath}:`, error);
113+
throw error;
114+
}
115+
}
116+
117+
// Let the default loader handle everything else
118+
return nextLoad(url, context);
119+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
@echo off
2+
REM OpenCode launcher for Node.js on Windows
3+
4+
setlocal
5+
6+
REM Get the directory where this script is located
7+
set SCRIPT_DIR=%~dp0
8+
9+
REM Build the node command with all arguments
10+
node --loader "%SCRIPT_DIR%loader.mjs" --conditions=browser --import "%SCRIPT_DIR%global-bun-shim.mjs" --import tsx/esm "%SCRIPT_DIR%src\index.ts" %*
11+
12+
endlocal

0 commit comments

Comments
 (0)