Node.js to support ESM Require: What this means for NestJS developers and other CommonJS frameworks

bendechrai

Ben Dechrai

Posted on September 3, 2024

Node.js to support ESM Require: What this means for NestJS developers and other CommonJS frameworks

JavaScript has two main module systems: CommonJS (CJS) and ECMAScript Modules (ESM). CJS, the older system, is used by Node.js and many popular frameworks like NestJS. ESM is the newer, standardized system used in modern JavaScript.

This difference has caused issues when trying to use ESM-only packages in CJS-based projects. Developers often resort to complex workarounds, typically modifying tsconfig.json, adjusting package.json, and ensuring all imports to relative files end with .js. This process can be error-prone and adds complexity to project maintenance.

Node.js 22 introduces the --experimental-require-module flag, enabling the use of require() to import ESM modules in a CommonJS context. This development significantly improves the ability for developers who use CJS-based frameworks to integrate ESM-only packages.

This article explores how this feature can simplify the use of ESM packages in NestJS applications, using Arcjet (ESM-only) as a practical example.

đź’ˇ Arcjet is a security suite for web applications. It offers advanced protection features including rate limiting, bot detection, email validation, and a multi-strategy security shield. By integrating Arcjet, you are significantly enhancing your application's defense against various online threats. In this tutorial, we'll implement rate limiting and shield.

Installing Node 22

Node 22 was released in Apr 2024 and is slated to enter LTS in October, but until then, you’re probably on Node 20. And in all likelihood, you’ll want to keep this version for your current development. So how can we easily run node 22 alongside it?

nvm, or Node Version Manager, allows us to run multiple versions of Node (and the correct version of npm alongside it) and switch between them easily from the command line.

If you already have node installed via another package manager (i.e. homebrew), you don’t need to uninstall that. nvm will manage our paths from now on, and both can co-exist happily. So first things first, let’s install nvm.

nvm’s installation guide suggests you run one of the following commands in your terminal. But don’t take my word for it - check the latest documentation for yourself before you start running random curl commands in blog posts that pipe to bash.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
Enter fullscreen mode Exit fullscreen mode

Once that’s finished, you’ll need to close and reopen your terminal to be able to run nvm, or you can run the following commands (taken from the aforementioned documentation) to load nvm into the current environment:

export NVM_DIR="$([-z "${XDG_CONFIG_HOME-}"] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[-s "$NVM_DIR/nvm.sh"] && \. "$NVM_DIR/nvm.sh"
Enter fullscreen mode Exit fullscreen mode

Now that we have nvm installed, let’s install the latest LTS version and version 22:

nvm install --lts
nvm install 22
Enter fullscreen mode Exit fullscreen mode

The active version of Node will now be the most recent one you installed (v22 in our case), and you can list the installed versions with nvm ls and switch between them using nvm use <version>, where version can be a number or --lts:

ben@localhost % nvm use 20
Now using node v20.16.0 (npm v10.8.1)

ben@localhost % nvm use 22
Now using node v22.6.0 (npm v10.8.2)

ben@localhost % nvm use --lts
Now using node v20.16.0 (npm v10.8.1)

ben@localhost % nvm use 22   
Now using node v22.6.0 (npm v10.8.2)
Enter fullscreen mode Exit fullscreen mode

Create a new NestJS app

Let’s install NestJS using their TypeScript starter project:

git clone https://github.com/nestjs/typescript-starter.git nestjs-node22
cd nestjs-node22
npm install
npm run start:dev
Enter fullscreen mode Exit fullscreen mode

Now when you head to http://localhost:3000/, you’ll see your “Hello World” page:

Node.js to support ESM Require: What this means for NestJS developers and other CommonJS frameworks

Enabling the Experimental Require Module in Node 22

Edit the package.json file and add NODE_OPTIONS='--experimental-require-module' to the build and start:* scripts:

{
  ...
  "scripts": {
    "build": "NODE_OPTIONS='--experimental-require-module' nest build",
    "start": "NODE_OPTIONS='--experimental-require-module' nest start",
    "start:dev": "NODE_OPTIONS='--experimental-require-module' nest start --watch",
    "start:debug": "NODE_OPTIONS='--experimental-require-module' nest start --debug --watch",
    "start:prod": "NODE_OPTIONS='--experimental-require-module' node dist/main",
Enter fullscreen mode Exit fullscreen mode

⚠️ It's important to note that the --experimental-require-module flag in Node.js 22 is, as the name suggests, experimental. While it offers exciting possibilities for integrating ESM modules in CommonJS environments, it may have unexpected behaviors or change in future Node.js versions. Use caution when considering this approach for production applications. Always thoroughly test your implementation and have a fallback plan. For critical systems, it may be prudent to wait until this feature becomes stable before deploying to production.

Now restart npm run start:dev and you’ll notice the experimental notice flash in the terminal before NestJS starts up.

Node.js to support ESM Require: What this means for NestJS developers and other CommonJS frameworks

Configuring NestJS for Arcjet

You’ll need an ARCJET_KEY to connect your NestJS application – create your free Arcjet account, and find it on the SDK Configuration page:

Node.js to support ESM Require: What this means for NestJS developers and other CommonJS frameworks

Copy that key, and add it to a new .env file in the root directory of your application, along with an ARCJET_ENV variable to tell Arcjet to accept local IP addresses (like localhost and 127.0.0.1) as we are in a development environment. (This is usually available from NODE_ENV, but NestJS doesn’t set it.)

ARCJET_KEY=ajkey_.........
ARCJET_ENV=development
Enter fullscreen mode Exit fullscreen mode

App Configuration with NestJS

NestJS centralizes your app's configuration with ConfigModule, making it easier to manage environment-specific settings and sensitive data like API keys. It works well with NestJS's dependency injection system and supports type safety. For the Arcjet integration, we'll use it to securely store the API key and define our environment.

Let’s install NestJS’s Config package:

npm install @nestjs/config
Enter fullscreen mode Exit fullscreen mode

Create a file /src/config/configuration.ts that exports a function to load environment variables:

export default () => ({
  // Load ARCJET_KEY from environment variables
  // or default to a blank string if not found
  arcjetKey: process.env.ARCJET_KEY || '',
});
Enter fullscreen mode Exit fullscreen mode

Create a new module in /src/config/config.module.ts to centralize configuration management, making environment variables and API keys easily accessible throughout the application.

import { Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule, ConfigService } from '@nestjs/config';
import configuration from './configuration';

@Module({
  imports: [
    NestConfigModule.forRoot({
      load: [configuration], // Load the custom configuration
      isGlobal: true,        // Make the ConfigModule global
    }),
  ],
  providers: [ConfigService],
  exports: [ConfigService],  // Export ConfigService to be used elsewhere
})

export class ConfigModule {}
Enter fullscreen mode Exit fullscreen mode

Edit your /src/app.module.ts file to import the custom ConfigModule in your AppModule. This ensures the configuration is properly loaded and accessible throughout your application.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module'; // Import the custom ConfigModule

@Module({
  imports: [ConfigModule], // Include the ConfigModule
  controllers: [AppController],
  providers: [AppService],
})

export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Installing and Configuring Arcjet

​​Let’s integrate Arcjet to secure every server-side request using Arcjet Shield and a Sliding Window rate-limit. We’ll do this by calling Arcjet’s protect() method from middleware.

Install Arcjet

npm install @arcjet/node
Enter fullscreen mode Exit fullscreen mode

Create the Arcjet Service

Create a file /src/arcjet/arcjet.service.ts which will initialize the Arcjet client with your configuration and provide a method to protect requests. This service encapsulates the Arcjet functionality, making it easy to use throughout your application.

import { Injectable } from '@nestjs/common';
import arcjet, { slidingWindow, shield } from '@arcjet/node';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class ArcjetService {
  private readonly arcjetClient;

  constructor(private configService: ConfigService) {
    // Retrieve ARCJET_KEY from the ConfigService
    const arcjetKey = this.configService.get<string>('arcjetKey');
    if (!arcjetKey) {
      throw new Error('ARCJET_KEY is not set');
    }

    this.arcjetClient = arcjet({
      key: arcjetKey,
      rules: [
        slidingWindow({
          mode: 'LIVE', // will block requests. Use "DRY_RUN" to log only
          interval: 60, // 60-second sliding window
          max: 10, // 10 requests per minute
        }),
        shield({
          mode: 'LIVE', // will block requests. Use "DRY_RUN" to log only
        }),
      ],
    });
  }

  async protect(req: Request) {
    return this.arcjetClient.protect(req);
  }
}
Enter fullscreen mode Exit fullscreen mode

Protecting All Routes

Create the Arcjet Middleware

Create a file /src/arcjet/arcjet.middleware.ts which will use the ArcjetService to protect each incoming request. This middleware will intercept all requests, apply Arcjet's protection rules, and determine whether to allow the request to proceed or to block it based on the Arcjet decision.

import { Injectable, NestMiddleware } from '@nestjs/common';
import { ArcjetService } from './arcjet.service';

@Injectable()
export class ArcjetMiddleware implements NestMiddleware {
  constructor(private readonly arcjetService: ArcjetService) {}

  async use(req, res, next) {

    try {
      const decision = await this.arcjetService.protect(req);

      if (decision.isDenied()) {
        if (decision.reason.isRateLimit()) {
          res
            .status(429)
            .json({ error: 'Too Many Requests', reason: decision.reason });
        } else {
          res.status(403).json({ error: 'Forbidden' });
        }
      } else {
        next();
      }
    } catch (error) {
      console.warn('Arcjet error', error);
      next(); // Fail open
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Update AppModule to Include ArcjetService and Apply ArcjetMiddleware

Now we need to update our app.module.ts file again to run ArcjetService and apply the ArcjetMiddleware to all routes in our NestJS application.

import { Module, MiddlewareConsumer } from '@nestjs/common'; // Add MiddlewareConsumer
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';
import { ArcjetService } from './arcjet/arcjet.service'; // Import ArcjetService
import { ArcjetMiddleware } from './arcjet/arcjet.middleware'; // Import ArcjetMiddleware

@Module({
  imports: [ConfigModule],
  controllers: [AppController],
  providers: [AppService, ArcjetService], // Register ArcjetService
})
export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    // Apply ArcjetMiddleware to all routes
    consumer.apply(ArcjetMiddleware).forRoutes('*');
  }
}
Enter fullscreen mode Exit fullscreen mode

The Result

Now head back to your development app at http://localhost:3000/ and refresh 11 times. The 11th should result in a rate-limit error:

Node.js to support ESM Require: What this means for NestJS developers and other CommonJS frameworks

Conclusion

In this tutorial, we've explored how Node.js 22's experimental ESM support allows us to seamlessly integrate Arcjet, an ESM-only package, into a CommonJS-based NestJS application. This breakthrough offers several key benefits:

  • Simplified dependency management: We can now use ESM-only packages without resorting to complex workarounds, opening up a wider range of tools and libraries for our NestJS projects.
  • Enhanced security: By integrating Arcjet, we've added robust protection against various threats and implemented rate limiting, significantly improving our application's security posture.
  • Improved maintainability: The use of NestJS's ConfigModule centralizes our configuration, making the app easier to manage and scale as it grows.

Moving forward, consider exploring Arcjet's additional security features to further fortify your application. Keep an eye on Node.js updates as this experimental feature matures, and always test thoroughly before deploying to production.

By staying ahead of the curve with these emerging technologies, you're well-positioned to build more secure, efficient, and maintainable NestJS applications. Happy coding, and stay secure!

đź’– đź’Ş đź™… đźš©
bendechrai
Ben Dechrai

Posted on September 3, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related