The Two Node.js Modules Every Coding Agent Depends On
Why Can't Mini Cursor Live Without path and fs? Node.js Path Handling and Asynchronous File Reading and Writing
In a minimal Mini Cursor, the model needs to read and write local files through tools.
The model can create frontend projects not because it directly obtains local file permissions, but because the Runtime registers file tools for it:
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
These three lines of code look ordinary, but they determine whether a file can be written to the correct location:
path is responsible for understanding and constructing paths
fs is responsible for actually operating on the file system
If path handling is wrong, the Agent might create files in the wrong directory; if synchronous I/O is used for time-consuming tasks, the Node.js main thread could be blocked.
Starting from a set of directly runnable minimal examples, this article breaks down the core usage of path and fs, as well as the evolution of asynchronous file reading from Callback to Promise, and then to async/await.
What Problems Does the path Module Solve?
path is a built-in Node.js module that requires no extra installation:
import path from 'path';
It does not read or modify the disk; it only handles path strings.
For example:
Joining directories and filenames
Resolving relative paths to absolute paths
Getting parent directories, filenames, and extensions
Eliminating duplicate separators, `.` and `..`
Breaking a full path into a structured object
There is another important reason to use path instead of manually concatenating strings: different operating systems may use different path separators.
POSIX: /a/b/c.js
Windows: C:\a\b\c.js
path organizes paths according to the current platform rules.
path.join(): Joining and Normalizing Paths
First, look at the most basic path joining:
console.log(path.join('a', 'b', 'c'));
On macOS and Linux, the output is:
a/b/c
join() does two things:
Connects all path segments together
Normalizes duplicate separators, `.` and `..`
For example:
path.join('/hello', 'world', '../a', 'b');
// /hello/a/b
A common misunderstanding here: join() is not just mechanical string concatenation; it also handles ...
Now use the current working directory as the first path segment:
console.log(path.join(process.cwd(), '/hello', 'world'));
Assuming the current working directory is /project/path_fs, the output is:
/project/path_fs/hello/world
process.cwd() returns the current working directory of the Node.js process.
Therefore, join() is well-suited for appending subdirectories or filenames after a known directory:
const srcDir = path.join(process.cwd(), 'src');
const configPath = path.join(srcDir, 'config', 'app.js');
path.resolve(): Resolving Absolute Paths from Right to Left
resolve() always returns an absolute path.
console.log(path.resolve('a', 'b', 'c'));
When all arguments are relative paths, it uses the current working directory as the starting point:
/current_working_directory/a/b/c
Its core rule is:
Process arguments from right to left until an absolute path is encountered;
Once an absolute path is encountered, arguments further to the left no longer participate in the result.
For example:
path.resolve(process.cwd(), '/hello', 'world');
// /hello/world
Although process.cwd() was passed in, the subsequent /hello is already an absolute path, so it overrides the preceding working directory.
Look at two more examples containing . and ..:
console.log(path.resolve('/hello', 'world', './a', 'b'));
// /hello/world/a/b
console.log(path.resolve('/hello', 'world', '../a', 'b'));
// /hello/a/b
resolve() handles ., .., and returns a normalized absolute path.
How to Choose Between join() and resolve()?
| Comparison | path.join() |
path.resolve() |
|---|---|---|
| Core function | Joins all path segments | Resolves to an absolute path |
| Always returns absolute path | No | Yes |
Handles ., .. |
Yes | Yes |
Depends on process.cwd() |
Does not actively depend | Uses it when no absolute argument exists |
| Encountering a later absolute path | Still joins as a segment | Discards paths to its left |
| Common scenario | Appending sub-paths after a known directory | Calculating project root or target absolute path |
You can remember it this way:
I want to append content after an existing path: join()
I want to get a definite absolute path: resolve()
But don't just memorize the mantra; especially note that resolve() can be reset by an absolute path on the right.
dirname(): Getting the Parent Directory
dirname() returns the directory portion of a path:
console.log(path.dirname('/a/b/c'));
// /a/b
dirname() returns the directory portion of a path.
It is crucial in Mini Cursor's write_file tool:
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
Suppose the model requests to write:
react-todo-app/src/components/TodoItem.tsx
dirname() will get:
react-todo-app/src/components
The Runtime creates the parent directory first, then writes the file. Otherwise, if the parent directory does not exist, writeFile() will fail directly.
basename(): Getting the Filename
path.basename('a/b/c.js');
// c.js
The second argument can remove a suffix that exactly matches the end:
path.basename('a/b/c.js', '.js');
// c
This is not "intelligent extension recognition," but ordinary end-of-string matching.
The following examples show that the second argument simply removes the literal end string:
path.basename('a/b/c.js', 'js');
// c.
path.basename('a/b/c.js', 's');
// c.j
Because both 'js' and 's' can match the end of the filename, the corresponding characters are removed, and the dot is not automatically handled.
If the goal is to first get the extension and then get the filename without the extension, you can write:
const filePath = 'a/b/c.js';
const ext = path.extname(filePath);
const name = path.basename(filePath, ext);
console.log(ext); // .js
console.log(name); // c
extname(): Getting the Extension
path.extname('a/b/c.js');
// .js
The return value includes the dot.
For multi-segment extensions:
path.extname('archive.tar.gz');
// .gz
It only returns the last segment of the extension.
normalize(): Normalizing Paths
For example:
path.normalize('a/b//c/d/e/..');
// a/b/c/d
normalize() handles:
Duplicate path separators
Current directory marker .
Return to parent directory ..
Current operating system path separator
It only processes the path string and does not check whether the corresponding file actually exists.
parse(): Breaking a Path into an Object
path.parse('/home/user/dir/file.txt');
Returns:
{
root: '/',
dir: '/home/user/dir',
base: 'file.txt',
ext: '.txt',
name: 'file'
}
When a tool needs to use the directory, filename, and extension simultaneously, parse() is more intuitive than calling multiple APIs consecutively.
The fs Module: Actually Operating on the File System
path only handles paths; fs is what actually reads and writes files.
Node.js provides different styles of file APIs:
import fs from 'fs';
import fs from 'fs/promises';
The first includes synchronous and callback APIs; the second provides Promise-style APIs.
readFileSync(): Synchronous Reading Blocks
First, look at reading a file synchronously:
const syncData = fs.readFileSync('./test.txt', 'utf-8');
readFileSync() must wait for the file read to complete before subsequent JavaScript can continue executing.
Start reading file
→ Current thread waits
→ File read completes
→ Execute next line of code
Synchronous APIs are sometimes simpler when reading small configuration files or loading fixed content during program startup.
But if a large file is read in a service that needs to continuously handle requests, the main thread will be unable to execute other JavaScript during this time.
readFile(): Asynchronous Reading and Error-First Callback
Callback-style asynchronous reading:
fs.readFile('./test.txt', 'utf-8', (err, data) => {
if (!err) {
console.log(data);
} else {
console.log(err);
}
});
console.log('111');
After readFile() initiates the file read, JavaScript can continue executing, so 111 is usually output first.
Traditional Node.js callbacks have a common convention:
The first parameter is the error object err
Subsequent parameters are the success results
This is the Error-First Callback.
How Does Callback Hell Emerge?
If three files must be read sequentially, callbacks nest layer by layer:
fs.readFile('./file1.txt', 'utf8', (err, data) => {
if (!err) {
console.log(data);
fs.readFile('./file2.txt', 'utf8', (err, data) => {
if (!err) {
console.log(data);
fs.readFile('./file3.txt', 'utf8', (err, data) => {
if (!err) {
console.log(data);
}
});
}
});
}
});
The problem with the code is not that it "cannot run," but that as business steps increase:
Nesting becomes deeper and deeper
Error handling is constantly repeated
The main flow is fragmented by a large number of brackets
Subsequent maintenance and modification become difficult
This is Callback Hell.
Promise: Turning Horizontal Nesting into a Chained Flow
readFile() in fs/promises returns a Promise:
import fs from 'fs/promises';
fs.readFile('./file1.txt', 'utf-8')
.then(data => {
console.log('file1', data);
return fs.readFile('./file2.txt', 'utf-8');
})
.then(data => {
console.log('file2', data);
return fs.readFile('./file3.txt', 'utf-8');
})
.then(data => {
console.log('file3', data);
});
Each .then() returns the next Promise, forming a vertical chain of three steps instead of continuously nesting to the right.
Real code should also handle errors uniformly:
.catch(error => {
console.error('File read failed:', error);
});
async/await: Making Asynchronous Flow Closer to Sequential Code
Using an immediately invoked async function, the three read steps can be written as:
import fs from 'fs/promises';
(async () => {
const file1Data = await fs.readFile('./file1.txt', 'utf-8');
console.log('file1', file1Data);
const file2Data = await fs.readFile('./file2.txt', 'utf-8');
console.log('file2', file2Data);
const file3Data = await fs.readFile('./file3.txt', 'utf-8');
console.log('file3', file3Data);
})();
await pauses the current async function, waits for the Promise to complete, then executes the next line.
But this is not the same as readFileSync():
readFileSync(): Blocks the current JavaScript thread
await readFile(): Pauses the current async function, yielding execution back to the event loop
async/await is syntactic form of Promise; it does not turn asynchronous I/O into synchronous blocking I/O.
A Diagram to Understand the Evolution of Asynchronous File Reading
This evolutionary path solves code organization and main thread blocking problems:
readFileSync: Direct syntax, but blocks
readFile + Callback: Asynchronous and non-blocking, but multiple steps easily nest
Promise.then: Turns nesting into chained calls
async/await: Preserves asynchronous nature while improving readability
How to Choose Between Sequential await and Promise.all?
If the business requires reading file1 → file2 → file3 sequentially, use consecutive await.
If the three files have no dependencies on each other, they can be initiated in parallel:
const [file1Data, file2Data, file3Data] = await Promise.all([
fs.readFile('./file1.txt', 'utf-8'),
fs.readFile('./file2.txt', 'utf-8'),
fs.readFile('./file3.txt', 'utf-8')
]);
The criterion is:
A later step depends on the result of a previous step: sequential await
Multiple tasks are independent of each other: Promise.all
How Do path and fs Form Mini Cursor's File Tools?
Put these capabilities back into Mini Cursor's write_file tool:
const writeFileTool = tool(
async ({ filePath, content }) => {
try {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
return `Successfully wrote ${filePath}`;
} catch (err) {
return `File write failed: ${err.message}`;
}
},
{
name: 'write_file',
description: 'Write file content to the specified path, automatically creating directories',
schema: z.object({
filePath: z.string().describe('File path'),
content: z.string().describe('Content to write to the file')
})
}
);
The execution order is:
Model generates filePath and content
→ path.dirname() gets the parent directory
→ fs.mkdir() creates missing directories
→ fs.writeFile() writes the file
→ Runtime returns the result to the model as a ToolMessage
Here, path is responsible for path correctness, fs is responsible for I/O, and async/await is responsible for making multiple asynchronous steps execute in a clear order.
Common Questions
Does join() handle ..?
Yes.
path.join('/hello', 'world', '../a', 'b');
// /hello/a/b
Both join() and resolve() normalize . and ..; the difference is not here.
Why doesn't resolve(process.cwd(), '/hello') use cwd?
Because resolve() processes from right to left, /hello is already an absolute path, so the process.cwd() to its left is ignored.
If you want to append hello under the current directory, do not write a leading slash:
path.resolve(process.cwd(), 'hello');
Aren't await and readFileSync() both "waiting"?
The way of waiting is different.
readFileSync() blocks the JavaScript thread; await fs.readFile() only pauses the current async function, and the event loop can still handle other tasks.
Why do relative paths sometimes fail to find files?
Relative paths are usually based on process.cwd(), which is the working directory when the Node.js process was started, and is not necessarily equal to the directory where the current module is located.
Therefore, if the same script is started from different directories, ./test.txt may point to different locations.
Summary
path and fs are the two most fundamental Node.js modules when a Coding Agent operates on local projects.
join(): Joins and normalizes paths
resolve(): Resolves absolute paths
dirname(): Gets the parent directory
basename(): Gets the filename and trims by literal suffix
extname(): Gets the last extension segment
normalize(): Normalizes path strings
parse(): Breaks a path into a structured object
File reading has gone through:
Synchronous blocking
→ Asynchronous callbacks
→ Promise chains
→ async/await
Mini Cursor's ability to correctly create directories, write code, and continue to the next round of ReAct depends on these three links:
path determines where the file should be placed
fs actually completes the file I/O
async/await organizes the asynchronous execution order
After understanding these fundamental capabilities, looking at an Agent's read_file, write_file, and list_directory is no longer just about knowing how to call the tools, but understanding what is really happening inside the tools.