10 modern Node.js runtime features to start using in 2024

SnykSec - May 30 - - Dev Community

The server-side JavaScript runtime scene has been packed with innovations, such as Bun making strides with compatible Node.js APIs and the Node.js runtime featuring a rich standard library and runtime capabilities.

As we enter into 2024, this article is a good opportunity to stay abreast of the latest features and functionalities offered by the Node.js runtime. Staying updated isn't just about “keeping with the times” — it's about leveraging the power of modern APIs to write more efficient, performant, and secure code.

This post will explore 10 modern Node.js runtime features that every developer should start using in 2024. We'll cover everything from fresh off-the-press APIs to the compelling features offered by new kids on the block like Bun and Deno.

Prerequisite: Node.js LTS version

Before you start exploring these modern features, ensure you're working with the Node.js LTS (long-term support) version. At the time of writing this article, the latest Node.js LTS version is v21.6.1.

To check your Node.js version, use the command:

node --version
Enter fullscreen mode Exit fullscreen mode

If you're not currently using the LTS version, consider using a version manager like fnm or nvm to easily switch between different Node.js versions.

What’s new in Node.js 20?

In the following sections, we’ll cover some new features introduced in recent versions of Node.js. Some are stable, others are still experimental, and a few have been supported even before, but you might not have heard of them just yet.

We’ll visit the following topics:

  1. Node.js test runner
  2. Node.js native mocking
  3. Node.js native test coverage
  4. Node.js watch mode
  5. Node.js corepack
  6. Node.js .env loader
  7. Node.js import.meta.file for __dirname and __file
  8. Node.js native timers promises
  9. Node.js permissions module
  10. Node.js policy module

The native Node.js test runner

What did we have before Node.js introduced a test runner in the native runtime? Up until now, you probably used one of the popular options, such as node-tap, jest, mocha, or vitest

Let’s learn how to leverage the Node.js native test runner in your development workflow. To begin, you need to import the test module from Node.js into your test file, as shown below:

import { test } from 'node:test';
Enter fullscreen mode Exit fullscreen mode

Now, let's walk through the different steps of using the Node.js test runner.

Running a single test with node:test

To create a single test, you use the test function, passing the name of the test and a callback function. The callback function is where you define your test logic.

import { test } from "node:test";
import assert from "node:assert";
import { add } from "../src/math.js";

test("should add two numbers", () => {
  const result = add(1, 2);
  assert.strictEqual(result, 3);
});

test("should fail to add strings", () => {
  assert.throws(() => {
    add("1", "2");
  });
});
Enter fullscreen mode Exit fullscreen mode

To run this test, you use the node --test command followed by the name of your test file:

node --test tests/math.test.js
Enter fullscreen mode Exit fullscreen mode

The Node.js test runner can automatically detect and run test files in your project. By convention, these files should end with .test.js but not strictly to this filename convention.

If you omit the test file positional argument, then the Node.js test runner will apply some heuristics and glob pattern matching to find test files, such as all files in a test/ or tests/ folder or files with a test- prefix or a .test suffix.

For example, glob matching test files:

node --test '**/*.test.js'
Enter fullscreen mode Exit fullscreen mode

Using test assertions with node:assert

Node.js test runner supports assertions through the built-in assert module. You can use different methods like assert.strictEqual to verify your tests.

import assert from 'node:assert';

test('Test 1', () => {
  assert.strictEqual(1 + 1, 2);
});
Enter fullscreen mode Exit fullscreen mode

Test suites & test hooks with the native Node.js test runner

The describe function is used to group related tests into a test suite. This makes your tests more organized and easier to manage.

import { test, describe } from "node:test";

describe('My Test Suite', () => {
  test('Test 1', () => {
    // Test 1 logic
  });

  test('Test 2', () => {
    // Test 2 logic
  });
});
Enter fullscreen mode Exit fullscreen mode

Test hooks are special functions that run before or after your tests. They are useful for setting up or cleaning up test environments.

test.beforeEach(() => {
  // Runs before each test
});

test.afterEach(() => {
  // Runs after each test
});
Enter fullscreen mode Exit fullscreen mode

You can also choose to skip a test using the test.skip function. This is helpful when you want to ignore a particular test temporarily.

test.skip('My skipped test', () => {
  // Test logic
});
Enter fullscreen mode Exit fullscreen mode

In addition, Node.js test runner provides different reporters that format and display test results in various ways. You can specify a reporter using the --reporter option.

node --test --test-reporter=tap
Enter fullscreen mode Exit fullscreen mode

Should you ditch Jest?

While Jest is a popular testing framework in the Node.js community, it has certain drawbacks that make the native Node.js test runner a more appealing choice.

By installing Jest, even as merely a dev dependency, you add 277 transitive dependencies of various licenses, including MIT, Apache-2.0, CC-BY-4.0, and 1 unknown license. Did you know that?

  • Jest modifies globals, which can lead to unexpected behaviors in your tests.
  • The instanceof operator doesn't always work as expected in Jest.
  • Jest introduces a large dependency footprint to your project, making it harder to stay up to date with third-party dependencies, and having to needlessly manage security issues and other concerns for dev-time dependencies.
  • Jest can be slower than the native Node.js test runner due to its overhead.

Other great features of the native Node.js test runner include running subtests and concurrent tests. Subtests allow each test() callback to receive a context argument that allows you to create nested tests via context.test. Concurrent tests are a great feature if you know how to work well with them and avoid racing conditions. Simply pass a concurrency: true potential object as the 2nd argument to the describe() test suite.

What is a test runner?

A test runner is a software tool that allows developers to manage and execute automated tests on their code. The Node.js test runner is a framework that is designed to work seamlessly with Node.js, providing a rich environment for writing and running tests on your Node.js applications.

Node.js native mocking

Mocking is one strategy developers employ to isolate code for testing. The Node.js runtime has introduced native mocking features, which are essential for developers to understand and use effectively.

You’ve probably used mocking features from other test frameworks such as Jest’s jest.spyOn, or mockResolvedValueOncel. They’re useful for when you want to avoid running actual code in your tests, such as HTTP requests or file system APIs, and change these operations with stubs and mocks that you can inspect later.

Unlike other Node.js runtime features like the watch and coverage functionality, mocking isn’t declared as experimental. However, it is subject to receive more changes as it’s a new feature that was only introduced in Node.js 18.

Node.js native mocking with import { mock } from 'node:test'

Let's look at how we can use the Node.js native mocking feature in a practical example. The test runner and module mocking feature is now available in Node.js 20 LTS as a stable feature.

We'll work with a utility module, dotenv.js, which loads environment variables from a .env file. We'll also use a test file, dotenv.test.js, which tests the dotenv.js module.

Here’s our very own in-house dotenv module:

// dotenv.js
import fs from "node:fs/promises";

export async function loadEnv(path = ".env") {
  const rawDataEnv = await fs.readFile(path, "utf8");
  const env = {};
  rawDataEnv.split("\n").forEach((line) => {
    const [key, value] = line.split("=");
    env[key] = value;
  });

  return env;
}
Enter fullscreen mode Exit fullscreen mode

In the dotenv.js file, we have an asynchronous function, loadEnv, which reads a file using the fs.readFile method and splits the file content into key-value pairs. As you can see, it uses the Node.js native file system API fs.

Now, let's see how we can test this function using the native mocking feature in Node.js.

// dotenv.test.js
import { describe, test, mock } from "node:test";
import assert from "node:assert";
import fs from "node:fs/promises";

import { loadEnv } from "../src/dotenv.js";

describe("dotenv test suite", () => {
  test("should load env file", async () => {
    const mockImplementation = async (path) => {
      return "PORT=3000\n";
    };
    const mockedReadFile = mock.method(fs, "readFile", mockImplementation);

    const env = await loadEnv(".env");

    assert.strictEqual(env.PORT, "3000");
    assert.strictEqual(mockedReadFile.mock.calls.length, 1);
  });
});
Enter fullscreen mode Exit fullscreen mode

In the test file, we import the mock method from node:test, which we use to create a mock implementation of fs.readFile. In the mock implementation, we return a string, "PORT=3000\n", regardless of the file path passed.

We then call the loadEnv function, and using the assert module, we check two things:

  1. The returned object has a PORT property with a value of "3000".
  2. The fs.readFile method was called exactly once.

By using the native mock functionality in Node.js, we're able to effectively isolate our loadEnv function from the file system and test it in isolation. Mocking capabilities with Node.js 20 also include support for mocking timers.

What is mocking?

In software testing, mocking is a process where the actual functionalities of specific modules are replaced with artificial ones. The primary goal is to isolate the unit of code being tested from external dependencies, ensuring that the test only verifies the functionality of the unit and not the dependencies. Mocking also allows you to simulate different scenarios, such as errors from dependencies, which might be hard to recreate consistently in a real environment.

Node.js native test coverage

What is test coverage?

Test coverage is a metric used in software testing. It helps developers understand the degree to which the source code of an application is being tested. This is crucial because it reveals areas of the codebase that have not been tested, enabling developers to identify potential weaknesses in their software.

Why is test coverage important? Well, it ensures the quality of software by reducing the number of bugs and preventing regressions. Additionally, it provides insights into the effectiveness of your tests and helps guide you toward a more robust, reliable, and secure application.

Utilizing native Node.js test coverage

Starting with version 20, the Node.js runtime includes native capabilities for test coverage. However, it's important to note that the native Node.js test coverage is currently marked as an experimental feature. This means that while it's available for use, there might be some changes in future releases.

To use the native Node.js test coverage, you need to use the --experimental-coverage command-line flag. Here's an example of how you can add a test:coverage entry in your package.json scripts field that runs your project tests:

{
  "scripts": {
    "test": "node --test ./tests",
    "test:coverage": "node --experimental-coverage --test ./tests"
  }
}
Enter fullscreen mode Exit fullscreen mode

In the example above, the test:coverage script utilizes the --experimental-coverage flag to generate coverage data during test execution.

After running npm run test:coverage, you should see an output similar to this:

ℹ tests 7
ℹ suites 4
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 1
ℹ todo 1
ℹ duration_ms 84.018917
ℹ start of coverage report
ℹ ---------------------------------------------------------------------
ℹ file                 | line % | branch % | funcs % | uncovered lines
ℹ ---------------------------------------------------------------------
ℹ src/dotenv.js        | 100.00 |   100.00 |  100.00 | 
ℹ src/math.js          | 100.00 |   100.00 |  100.00 | 
ℹ tests/dotenv.test.js | 100.00 |   100.00 |  100.00 | 
ℹ tests/math.test.js   |  94.64 |   100.00 |   91.67 | 24-26
ℹ ---------------------------------------------------------------------
ℹ all files            |  96.74 |   100.00 |   94.44 |
ℹ ---------------------------------------------------------------------
ℹ end of coverage report
Enter fullscreen mode Exit fullscreen mode

This report displays the percentage of statements, branches, functions, and lines covered by the tests.

The Node.js native test coverage is a powerful tool that can help you improve the quality of your Node.js applications. Even though it's currently marked as an experimental feature, it can provide valuable insights into your test coverage and guide your testing efforts. By understanding and leveraging this feature, you can ensure that your code is robust, reliable, and secure.

Node.js watch mode

The Node.js watch mode is a powerful developer feature that allows for real-time tracking of changes to your Node.js files and automatic re-execution of scripts.

Before diving into Node.js's native watch capabilities, it's essential to acknowledge nodemon, a popular utility that helped fill this need in earlier versions of Node.js. Nodemon is a command-line interface (CLI) utility developed to restart the Node.js application when any change is detected in the file directory.

npm install -g nodemon
nodemon
Enter fullscreen mode Exit fullscreen mode

This feature is particularly useful during the development process. It saves time and improves productivity by eliminating the need for manual restarts each time a file is modified.


With advancements in Node.js itself, the language now provides built-in functionality to achieve the same results. This negates the need to install extra third-party dependencies in your projects like nodemon.

Before we dive into the tutorial, it's important to note that the native watch mode feature in Node.js is still experimental and may be subject to changes. Always ensure you're using a Node.js version that supports this feature.

Using Node.js 20 native watch capabilities

Node.js 20 introduces native file watch capabilities using the --watch command line flag. This feature is straightforward to use and can even match glob patterns for more complex file-watching needs.

To use the --watch command, append it to your Node.js script in the command line as shown below:

node --watch app.js
Enter fullscreen mode Exit fullscreen mode

In the case of glob patterns, you can use the --watch flag with a specific pattern to watch multiple files or directories. This is particularly useful when you want to watch a group of files that match a specific pattern:

node --watch 'lib/**/*.js' app.js
Enter fullscreen mode Exit fullscreen mode

The --watch flag can also be used in conjunction with --test to re-run tests whenever test files change:

node --watch --test '**/*.test.js'
Enter fullscreen mode Exit fullscreen mode

This combination can significantly speed up your test-driven development (TDD) process by automatically running your tests every time you make a change.

It's important to note that as of Node.js 20, the watch mode feature is still marked as experimental. This means that while the feature is fully functional, it may not be as stable or as optimized as other non-experimental features.

In practice, you might encounter some quirks or bugs when using the --watch flag. 

Node.js Corepack

Node.js Corepack is an intriguing feature that is worth exploring. It was introduced in Node.js 16 and is still marked as experimental. This makes it even more exciting to take a look at what it offers and how it can be leveraged in your JavaScript projects.

What is Corepack?

Corepack is a zero-runtime-dependency project that acts as a bridge between Node.js projects and the package managers they are intended to use. When installed, it provides a program called corepack that developers can use in their projects to ensure they have the right package manager without having to worry about its global installation. 

Why use Corepack?

As JavaScript developers, we often deal with multiple projects, each potentially having its own preferred package manager. You know how it is, one project manages its dependencies with pnpm and another project with yarn, so you end up having to jump around different versions of package managers too.

This can lead to conflicts and inconsistencies. Corepack solves this problem by allowing each project to specify and use its preferred package manager in a seamless way. 

Moreover, Corepack provides isolation between your project and the global system, ensuring that your project will stay runnable even if global packages get upgraded or removed. This increases the consistency and reliability of your project.

Installing and using Corepack

Installing Corepack is quite straightforward. Since it is bundled with Node.js starting from version 16, you only need to install or upgrade Node.js to that version or later. 

Once installed, you can define the package manager for your project in your package.json file like this:

{
  "packageManager": "yarn@2.4.1"
}
Enter fullscreen mode Exit fullscreen mode

Then, you can use Corepack in your project like this:

corepack enable
Enter fullscreen mode Exit fullscreen mode

If you type yarn in the project directory and you don’t have Yarn installed, then Corepack will automatically detect and install the right version for you.

This will ensure that Yarn version 2.4.1 is used to install your project's dependencies, regardless of the global Yarn version installed on the system.

If you want to install Yarn globally or use a specific version, you can run:

corepack install --global yarn@stable
Enter fullscreen mode Exit fullscreen mode

Corepack: Still an experimental feature

Despite its introduction in Node.js 16, Corepack is still marked as experimental. This means that while it's expected to work well, it's still under active development, and some aspects of its behavior might change in the future.

That said, Corepack is easy to install, simple to use, and provides an extra layer of reliability to your projects. It's definitely a feature worth exploring and incorporating into your development workflow.

Node.js .env loader

Application configuration is crucial, and as a Node.js developer, I’m sure this has met your needs to manage API credentials, server port numbers, or database configurations.

As developers, we need a way to provide different settings for different environments without changing the source code. One popular way to achieve this in Node.js applications is by using environment variables stored in .env files.

The dotenv npm package

Before Node.js introduced native support for loading .env files, developers primarily used the dotenv npm package. The dotenv package loads environment variables from a .env file into process.env, which are then available throughout the application.

Here is a typical usage of the dotenv package:

require('dotenv').config();

console.log(process.env.MY_VARIABLE);
Enter fullscreen mode Exit fullscreen mode

This worked well, but it required adding an additional dependency to your project. With the introduction of the native .env loader, you can now load your environment variables directly without needing any external packages.

Introducing native support in Node.js for loading .env files

Starting from Node.js 20, the runtime now includes a built-in feature to load environment variables from .env files. This feature is under active development but has already become a game-changer for developers.

To load a .env file, we can use the --env-file CLI flag when starting our Node.js application. This flag specifies the path to the .env file to be loaded.

node --env-file=./.env index.js
Enter fullscreen mode Exit fullscreen mode

This will load the environment variables from the specified .env file into process.env. The variables are then available within your application just like before.

Loading multiple .env files

The Node.js .env loader also supports loading multiple .env files. This is useful when you have different sets of environment variables for different environments (e.g., development, testing, production).

You can specify multiple --env-file flags to load multiple files. The files are loaded in the order they are specified, and variables from later files overwrite those from earlier ones.

Here's an example:

node --env-file=./.env.default --env-file=./.env.development index.js
Enter fullscreen mode Exit fullscreen mode

In this example, ./.env.default contains the default variables, and ./.env.development contains the development-specific variables. Any variables in ./.env.development that also exist in ./.env.default will overwrite the ones in ./.env.default.

The native support for loading .env files in Node.js is a significant improvement for Node.js developers. It simplifies configuration management and eliminates the need for an additional package. Start using the --env-file CLI flag in your Node.js applications and experience the convenience first-hand.

Node.js import.meta support for __dirname and __file

If you’re coming from CommonJS module conventions for Node.js, then you’re used to working with filename and __dirname as a way to get the current file’s directory name and file path. However, until recently, these weren’t easily available on ESM, and you had to come up with the following code to extract the __dirname:

import url from 'url'
import path from 'path'
const dirname = path.dirname(url.fileURLToPath(import.meta.url))
Enter fullscreen mode Exit fullscreen mode

Or if you’re a Matteo Collina fan, you might have sorted out to use Matteo’s desm npm package.


Node.js continually evolves to offer developers more efficient ways to handle file and path operations. One significant change that will benefit Node.js developers has been introduced in Node.js v20.11.0 and Node.js v21.2.0 with built-in support for import.meta.dirname and import.meta.filename.

Using Node.js import.meta.filename and import.meta.dirname

Thankfully, with the introduction of import.meta.filename and import.meta.dirname, this process has become much easier. Let's look at an example of loading a configuration file using the new features. 

Assume there is a YAML configuration file in the same directory as your JavaScript file that you need to load. Here's how you can do it:

import fs from 'fs';

const { dirname: __dirname, filename: __filename } = import.meta;
const projectSetup = fs.readFileSync(`${__dirname}/setup.yml`, "utf8");

console.log(projectSetup);
Enter fullscreen mode Exit fullscreen mode

In this example, we use import.meta.dirname to get the directory name of the current file and assign it to the __dirname variable for CommonJS convenience of cod conventions.

Node.js native timers promises

Node.js, a popular JavaScript runtime built on Chrome’s V8 JavaScript engine, has always striven to make the lives of developers easier with constant updates and new features.

Despite Node.js introducing support for natively using timers with a promises syntax way back in Node.js v15, I admit I haven’t been regularly using them.

JavaScript's setTimeout() and setInterval() timers: A brief recap

Before diving into native timer promises, let's briefly recap the JavaScript setTimeout() and setInterval() timers. 

The setTimeout() API is a JavaScript function that executes a function or specified piece of code once the timer expires.

setTimeout(function(){ 
    console.log("Hello World!"); 
}, 3000);
Enter fullscreen mode Exit fullscreen mode

In the above code, "Hello World!" will be printed to the console after 3 seconds (3000 milliseconds).

setInterval(), on the other hand, repeatedly executes the specified function with a delay between each call.

setInterval(function(){ 
    console.log("Hello again!"); 
}, 2000);
Enter fullscreen mode Exit fullscreen mode

In the above code, "Hello again!" will be printed to the console every 2 seconds (2000 milliseconds).

The old way: Wrapping setTimeout() with a promise

In the past, developers would often have to artificially wrap the setTimeout() function with a promise to use it asynchronously. This was done to allow the use of setTimeout() with async/await.

Here is an example of how it was done:

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function demo() {
  console.log('Taking a break...');
  await sleep(2000);
  console.log('Two seconds later...');
}

demo();
Enter fullscreen mode Exit fullscreen mode

This would print "Taking a break...", wait for two seconds, and then print "Two seconds later...". 

While this worked, it added unnecessary complexity to the code. 

Node.js Native timer promises: A simpler way

With Node.js Native timer promises, we no longer need to wrap setTimeout() in a promise. Instead, we can use setTimeout() directly with async/await. This makes the code cleaner, more readable, and easier to maintain. Here is an example of how to use Node.js native timer promises:

const {
  setTimeout,
} = require('node:timers/promises');

setTimeout(2000, 'Two seconds later...').then((res) => {
  console.log(res);  
});

console.log('Taking a break...');
Enter fullscreen mode Exit fullscreen mode

In the above code, setTimeout() is imported from node:timers/promises. We then use it directly with async/await. It will print "Taking a break...", wait for two seconds, and then print "Two seconds later...".

This greatly simplifies asynchronous programming and makes the code easier to read, write, and maintain.

Node.js permissions model

Rafael Gonzaga, now on the Node.js TSC, revived the work on Node.js permission module, which, similarly to Deno, provides a process-level set of configurable resource constraints.

In the world of supply-chain security concerns, malicious npm packages, and other security risks, it’s becoming increasingly crucial to manage and control the resources your Node.js applications have access to for security and compliance reasons.

In this respect, Node.js has introduced an experimental feature known as the permissions module, which is used to manage resource permissions in your Node.js applications. This feature is enabled using the --experimental-permission command-line flag.

Node.js resource permissions model

The permissions model in Node.js provides an abstraction for managing access to various resources like file systems, networks, environment variables, and worker threads, among others. This feature is particularly useful when you want to limit the resources a certain part of your application can access.

Common resource constraints you can set with the permissions model include:

  • File system read and write with --allow-fs-read=* and --allow-fs-write=*, and you can specify directories and specific file paths, as well as provide multiple resources by repeating the flags
  • Child process invocations with --allow-child-process
  • Worker threads invocations with --allow-worker

The Node.js permissions model also provides a runtime API via process.permission.has(resource, value) to allow querying for specific access.

If you try accessing resources that aren’t allowed, for example, to read the .env file, you’ll see an ERR_ACCESS_DENIED error:

> start:protected
> node --env-file=.env --experimental-permission server.js

node:internal/modules/cjs/loader:197
  const result = internalModuleStat(filename);
                 ^

Error: Access to this API has been restricted
    at stat (node:internal/modules/cjs/loader:197:18)
    at Module._findPath (node:internal/modules/cjs/loader:682:16)
    at resolveMainPath (node:internal/modules/run_main:28:23)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:135:24)
    at node:internal/main/run_main_module:28:49 {
  code: 'ERR_ACCESS_DENIED',
  permission: 'FileSystemRead',
  resource: '/Users/lirantal/repos/modern-nodejs-runtime-features-2024/server.js'
}

Node.js v21.6.1
Enter fullscreen mode Exit fullscreen mode

Node.js permission model example

Consider a scenario where you have a Node.js application that handles file uploads. You want to restrict this part of your application so that it only has access to a specific directory where the uploaded files are stored.

Enable the experimental permissions feature when starting your Node.js application with the --experimental-permission flag.

node --experimental-permission ./app.js
Enter fullscreen mode Exit fullscreen mode

We also want to specifically allow the application to read 2 trusted files, .env and setup.yml, so we need to update the above to this:

node --experimental-permission --allow-fs-write=/tmp/uploads --allow-fs-read=.env --allow-fs-read=setup.yml ./app.js
Enter fullscreen mode Exit fullscreen mode

In this way, if the application attempts to access file-based system resources for write purposes outside of the provided upload path, it will halt with an error.

See the following code example for how to wrap a resource access via try/catch as well as using the Node.js permissions runtime API as another way of ensuring access without an error exception thrown:

 const { dirname: __dirname, filename: __filename } = import.meta;
// @TODO to avoid the Node.js resource permission issue you should update
// the path to be `setup.yml` in the current directory and not `../setup.yml`.
// the outside path for setup.yml was only changed in the source code to
// show you how Node.js resource permission module will halt if trying to access
// something outside the current directory.
const filePath = `${__dirname}/../setup.yml`;
try {
  const projectSetup = fs.readFileSync(filePath, "utf8");
  // @TODO do something with projectSetup if you want to
} catch (error) {
  console.error(error.code);
}
// @TODO or consider using the permissions runtime API check:
if (!process.permission.has("read", filePath)) {
  console.error("no permissions to read file at", filePath);
}
Enter fullscreen mode Exit fullscreen mode

It's important to note that the permissions functionality in Node.js is still experimental and subject to changes.

On this topic of permissions and production-grade conventions for security, you can find more information on how to build secure Node.js applications, check out these blog posts by Snyk:

These posts provide a comprehensive guide on building secure container images for Node.js web applications, which is critical in developing secure Node.js applications.

Node.js policy module

The Node.js policy module is a security feature designed to prevent malicious code from loading and executing in a Node.js application. While it doesn't trace the origin of the loaded code, it provides a solid defense mechanism against potential threats.

The policy module leverages the --experimental-policy CLI flag to enable policy-based code loading. This flag takes a policy manifest file (in JSON format) as an argument. For instance, --experimental-policy=policy.json.

The policy manifest file contains the policies that Node.js adheres to when loading modules. This provides a robust way to control the nature of code that gets loaded into your application.

Implementing Node.js policy module: A step-by-step guide

Let's walk through a simple example to demonstrate how to use the Node.js policy module:

1. Create a policy file. The file should be a JSON file specifying your app's policies for loading modules. Let's call it policy.json

For instance:

    {
      "resources": {
        "./moduleA.js": {
          "integrity": "sha384-xxxxx"
        },
        "./moduleB.js": {
          "integrity": "sha384-yyyyy"
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

This policy file specifies that moduleA.js and moduleB.js should have specific integrity values to be loaded.

However, generating the policy file for all of your direct and transitive dependencies isn’t straightforward. A few years back, Bradley Meck created the node-policy npm package, which provides a CLI to automate the generation of the policy file.

2. Run your Node.js application with the --experimental-policy flag:

  node --experimental-policy=policy.json app.js
Enter fullscreen mode Exit fullscreen mode

This command tells Node.js to adhere to the policies specified in policy.json when loading modules in app.js.

3. To guard against tampering with the policy file, you can provide an integrity value for the policy file itself using the --policy-integrity flag:

    node --experimental-policy=policy.json --policy-integrity="sha384-zzzzz" app.js
Enter fullscreen mode Exit fullscreen mode

This command ensures that the policy file's integrity is maintained, even if the file is changed on disk.

Caveats with Node.js integrity policy

There are no built-in capabilities by the Node.js runtime to generate or manage the policy file and it could potentially introduce difficulties such as managing different policies based on production vs development environments as well as dynamic module imports.

Another caveat is that if you already have a malicious npm package in its current state, it’s too late to generate a module integrity policy file.

I personally advise you to watch for updates in this area and slowly attempt a gradual adoption of this feature.

For more information on Node.js policy module, you can check out the article on introducing experimental integrity policies to Node.js, which provides a more detailed step-by-step tutorial on working with Node.js policy integrity.

Wrapping-up

As we have traversed through the modern Node.js runtime features that you should start using in 2024, it becomes clear that these capabilities are designed to streamline your development process, enhance application performance, and reinforce security. These features are not just trendy. They have substantial potential to redefine the way we approach Node.js development.

Strengthen your Node.js security with Snyk

While these Node.js features can significantly enhance your development process and application performance, it is essential to remain vigilant about potential security threats. Snyk can be your ally in this endeavor. This powerful tool helps you find and fix known vulnerabilities in your Node.js dependencies and maintain a secure development ecosystem.

To take advantage of what Snyk has to offer, sign up here for free and begin your journey towards more secure Node.js development.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .