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
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:
- Node.js test runner
- Node.js native mocking
- Node.js native test coverage
- Node.js watch mode
- Jest modifies globals, which can lead to unexpected behaviors in your tests.
- The
instanceofoperator 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 acontextargument that allows you to create nested tests viacontext.test. Concurrent tests are a great feature if you know how to work well with them and avoid racing conditions. Simply pass aconcurrency: truepotential object as the 2nd argument to thedescribe()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, ormockResolvedValueOncel. 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 thedotenv.jsmodule.
Here’s our very own in-house dotenv module:
CODE// 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;
}
In the
dotenv.jsfile, we have an asynchronous function,loadEnv, which reads a file using thefs.readFilemethod and splits the file content into key-value pairs. As you can see, it uses the Node.js native file system APIfs.
Now, let's see how we can test this function using the native mocking feature in Node.js.
CODE// 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);
});
});
In the test file, we import the
mockmethod fromnode:test, which we use to create a mock implementation offs.readFile. In the mock implementation, we return a string,"PORT=3000\n", regardless of the file path passed.
We then call the
loadEnvfunction, and using theassertmodule, we check two things:
- The returned object has a
PORTproperty with a value of"3000". - The
fs.readFilemethod was called exactly once.
By using the native mock functionality in Node.js, we're able to effectively isolate our
loadEnvfunction 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-coveragecommand-line flag. Here's an example of how you can add atest:coverageentry in yourpackage.jsonscripts field that runs your project tests:
CODE{
"scripts": {
"test": "node --test ./tests",
"test:coverage": "node --experimental-coverage --test ./tests"
}
}
In the example above, the
test:coveragescript utilizes the--experimental-coverageflag to generate coverage data during test execution.
After running
npm run test:coverage, you should see an output similar to this:
CODEℹ 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
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
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 likenodemon.
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
--watchcommand line flag. This feature is straightforward to use and can even match glob patterns for more complex file-watching needs.
To use the
--watchcommand, append it to your Node.js script in the command line as shown below:
CODEnode --watch app.js
In the case of glob patterns, you can use the
--watchflag 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:
CODEnode --watch 'lib/**/*.js' app.js
The
--watchflag can also be used in conjunction with--testto re-run tests whenever test files change:
CODEnode --watch --test '**/*.test.js'
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
--watchflag.
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
corepackthat 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
pnpmand another project withyarn, 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.jsonfile like this:
Then, you can use Corepack in your project like this:
CODEcorepack enable
If you type
yarnin 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:
CODEcorepack install --global yarn@stable
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
.envfiles.
The dotenv npm package
Before Node.js introduced native support for loading
.envfiles, developers primarily used thedotenvnpm package. Thedotenvpackage loads environment variables from a.envfile intoprocess.env, which are then available throughout the application.
Here is a typical usage of the
dotenvpackage:
CODErequire('dotenv').config();
console.log(process.env.MY_VARIABLE);
This worked well, but it required adding an additional dependency to your project. With the introduction of the native
.envloader, 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
.envfiles. This feature is under active development but has already become a game-changer for developers.
To load a
.envfile, we can use the--env-fileCLI flag when starting our Node.js application. This flag specifies the path to the.envfile to be loaded.
CODEnode --env-file=./.env index.js
This will load the environment variables from the specified
.envfile intoprocess.env. The variables are then available within your application just like before.
Loading multiple .env files
The Node.js
.envloader also supports loading multiple.envfiles. This is useful when you have different sets of environment variables for different environments (e.g., development, testing, production).
You can specify multiple
--env-fileflags 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:
CODEnode --env-file=./.env.default --env-file=./.env.development index.js
In this example,
./.env.defaultcontains the default variables, and./.env.developmentcontains the development-specific variables. Any variables in./.env.developmentthat also exist in./.env.defaultwill overwrite the ones in./.env.default.
The native support for loading
.envfiles 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-fileCLI 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
filenameand__dirnameas 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:
CODEimport url from 'url'
import path from 'path'
const dirname = path.dirname(url.fileURLToPath(import.meta.url))
Or if you’re a Matteo Collina fan, you might have sorted out to use Matteo’s
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 forimport.meta.dirnameandimport.meta.filename.
Using Node.js import.meta.filename and import.meta.dirname
Thankfully, with the introduction of
import.meta.filenameandimport.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:
CODEimport fs from 'fs';
const { dirname: __dirname, filename: __filename } = import.meta;
const projectSetup = fs.readFileSync(`${__dirname}/setup.yml`, "utf8");
console.log(projectSetup);
In this example, we use
import.meta.dirnameto get the directory name of the current file and assign it to the__dirnamevariable 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()andsetInterval()timers.
The
setTimeout()API is a JavaScript function that executes a function or specified piece of code once the timer expires.
CODEsetTimeout(function(){
console.log("Hello World!");
}, 3000);
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.
CODEsetInterval(function(){
console.log("Hello again!");
}, 2000);
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 ofsetTimeout()with async/await.
Here is an example of how it was done:
CODEfunction 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();
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 usesetTimeout()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:
CODEconst {
setTimeout,
} = require('node:timers/promises');
setTimeout(2000, 'Two seconds later...').then((res) => {
console.log(res);
});
console.log('Taking a break...');
In the above code,
setTimeout()is imported fromnode: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-permissioncommand-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
.envfile, you’ll see anERR_ACCESS_DENIEDerror:
CODE> 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
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-permissionflag.
CODEnode --experimental-permission ./app.js
We also want to specifically allow the application to read 2 trusted files,
.envandsetup.yml, so we need to update the above to this:
CODEnode --experimental-permission --allow-fs-write=/tmp/uploads --allow-fs-read=.env --allow-fs-read=setup.yml ./app.js
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:
CODEconst { 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);
}
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-policyCLI 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:
CODE{
"resources": {
"./moduleA.js": {
"integrity": "sha384-xxxxx"
},
"./moduleB.js": {
"integrity": "sha384-yyyyy"
}
}
}
This policy file specifies that
moduleA.jsandmoduleB.jsshould 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-policyflag:
CODEnode --experimental-policy=policy.json app.js
This command tells Node.js to adhere to the policies specified in
policy.jsonwhen loading modules inapp.js.
3. To guard against tampering with the policy file, you can provide an integrity value for the policy file itself using the
--policy-integrityflag:
CODEnode --experimental-policy=policy.json --policy-integrity="sha384-zzzzz" app.js
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 and begin your journey towards more secure Node.js development.
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
SOCIAL SHARE CARD GENERATOR