跪拜 Guibai
← Back to the summary

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

fs-async-evolution@2x.png

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.