6.2.1 • Published 5 months ago

@bechara/crux v6.2.1

Weekly downloads
-
License
MIT
Repository
github
Last release
5 months ago

CRUX

Quality Gate Status Coverage Maintainability Rating Reliability Rating Security Rating

CRUX is an opinionated Node.js framework package designed for backend projects. It integrates a range of libraries and patterns commonly used in distributed, stateless applications that require database access.


Disclaimer

This framework was created to avoid rebuilding similar boilerplates for distributed, stateless backend projects with database access. It is highly opinionated and should be treated more as a reference for creating your own solutions rather than as a production-ready product.


Installation

  1. Create and initialize a new Node.js project, then install TypeScript, its types, a live-reload tool, and this package.

    We recommend using pnpm as your package manager, and ts-node-dev for live reloading:

    mkdir my-project
    cd my-project
    
    git init
    npm init -y
    
    npm i -g pnpm
    pnpm i -D typescript @types/node ts-node-dev
    pnpm i -E @bechara/crux
    
    tsc --init
  2. Create a main.ts file in a /source folder with the following content:

    // /source/main.ts
    import { AppModule } from '@bechara/crux';
    
    void AppModule.boot();
  3. Add a dev script in your package.json:

    {
      "scripts": {
        "dev": "tsnd --exit-child --rs --watch *.env --inspect=0.0.0.0:9229 ./source/main.ts"
      }
    }
  4. Start the application:

    pnpm dev

    You can test it by sending a request to GET /. You should receive a successful response with a 204 status code.


Development

Using this framework mostly follows the official NestJS Documentation. Familiarize yourself with the following core NestJS concepts before continuing:

Key Differences

  1. Imports from @bechara/crux
    All NestJS imports, such as @nestjs/common or @nestjs/core, are re-exported by @bechara/crux.
    Instead of:

    import { Injectable } from '@nestjs/common';

    use:

    import { Injectable } from '@bechara/crux';
  2. Automatic Module Loading
    Any file ending with *.module.ts in your source folder is automatically loaded by main.ts. You don’t need to create a global module importing them manually.
    Instead of:

    @Global()
    @Module({
      imports: [
        FooModule,
        BarModule,
        BazModule,
      ],
    })
    export class AppModule { }

    simply do:

    import { AppModule } from '@bechara/crux';
    
    void AppModule.boot();
    // FooModule, BarModule, and BazModule are automatically loaded
    // as long as they're in the source folder and named *.module.ts

Testing

Testing can involve multiple environment variables, making it more complex to write boilerplate code. For this reason, AppModule offers a built-in compile() method to create an application instance without serving it.

Usage

In your *.service.spec.ts, add a beforeAll() hook to compile an application instance:

import { AppModule } from '@bechara/crux';

describe('FooService', () => {
  let fooService: FooService;

  beforeAll(async () => {
    const app = await AppModule.compile();
    fooService = app.get(FooService);
  });

  describe('readById', () => {
    it('should read a foo entity', async () => {
      const foo = await fooService.readById(1);
      expect(foo).toEqual({ name: 'bob' });
    });
  });
});

If you need custom options, the compile() method supports the same boot options as boot().

Run all tests with:

pnpm test

Or a specific set:

pnpm test -- foo

Curated Modules

Below are details about the main modules in this framework and how to use them.

Application Module

Acts as the entry point, wrapping other modules in this package and automatically loading any *.module.ts in your source folder.

By default, it serves an HTTP adapter using Fastify. The following custom enhancers are globally applied:

Environment Configuration

VariableMandatoryType
NODE_ENVYesAppEnvironment

Module Options

When booting your application, you can configure options as described in AppBootOptions:

import { AppModule } from '@bechara/crux';

void AppModule.boot({
  // See AppBootOptions for detailed properties
});

Provided options will be merged with the default configuration.


Configuration Module

Allows asynchronous population of secrets through *.config.ts files containing configuration classes.

Decorate a class with @Config() to make it available as a regular NestJS provider. Any property decorated with @InjectSecret() will have its value extracted from process.env and injected into the class.

Usage

  1. Create a *.config.ts file with a class decorated by @Config().
  2. Decorate any properties with @InjectSecret().
  3. Optionally, apply class-validator and class-transformer decorators for validation and transformation.

Example:

import { Config, InjectSecret, IsUrl, IsString, Length, ToNumber } from '@bechara/crux';

@Config()
export class FooConfig {
  @InjectSecret()
  @IsUrl()
  FOO_API_URL: string;

  @InjectSecret({ key: 'foo_authorization' })
  @IsString() @Length(36)
  FOO_API_KEY: string;

  @InjectSecret({ fallback: '15' })
  @ToNumber()
  FOO_API_MAX_CONCURRENCY: number;
}

Use the configuration in your module and services:

@Injectable()
export class FooService {
  constructor(private readonly fooConfig: FooConfig) {}

  public async readFooById(id: number) {
    console.log(this.fooConfig.FOO_API_MAX_CONCURRENCY);
    // ...
  }
}

Context Module

Provides ContextService, an alternative to REQUEST-scoped injections in NestJS. It leverages Node.js AsyncLocalStorage to store request data without the performance or dependency-resolution challenges of REQUEST scope.

Usage

import { ContextService } from '@bechara/crux';

@Injectable()
export class FooService {
  constructor(private readonly contextService: ContextService) {}

  public getRequestAuthorization() {
    const req = this.contextService.getRequest();
    return req.headers.authorization;
  }

  public getUserId() {
    return this.contextService.getMetadata('userId');
  }

  public setUserId(userId: string) {
    this.contextService.setMetadata('userId', userId);
  }
}

Documentation Module

Generates OpenAPI documentation using NestJS OpenAPI Decorators.

  • User interface: available at /docs
  • OpenAPI spec: available at /docs/json

Http Module

Provides a wrapper over Node.js Fetch API, exposing methods to make HTTP requests. Its scope is transient: every injection yields a fresh instance.

Basic Usage

In your module:

import { HttpModule } from '@bechara/crux';

@Module({
  imports: [HttpModule.register()],
  controllers: [FooController],
  providers: [FooService],
})
export class FooModule {}

In your service:

import { HttpService } from '@bechara/crux';

@Injectable()
export class FooService {
  constructor(private readonly httpService: HttpService) {}

  public async readFooById(id: number) {
    return this.httpService.get('https://foo.com/foo/:id', {
      replacements: { id },
    });
  }
}

Async Registration

To configure base parameters (host, headers, API keys, etc.) using environment secrets:

import { HttpAsyncModuleOptions, HttpModule } from '@bechara/crux';

const httpModuleOptions: HttpAsyncModuleOptions = {
  inject: [FooConfig],
  useFactory: (fooConfig: FooConfig) => ({
    prefixUrl: fooConfig.FOO_API_URL,
    headers: { authorization: fooConfig.FOO_API_KEY },
    timeout: 20_000,
  }),
};

@Module({
  imports: [HttpModule.registerAsync(httpModuleOptions)],
  controllers: [FooController],
  providers: [FooConfig, FooService],
  exports: [FooConfig, FooService],
})
export class FooModule {}

Cache Module

Allows caching of inbound responses for controller paths decorated with @Cache(). Uses Redis (through ioredis) if available, falling back to an in-memory store.

Usage

import { Cache, Controller, Get, Param } from '@bechara/crux';

@Controller('foo')
export class FooController {
  constructor(private readonly fooService: FooService) {}

  @Cache({ ttl: 60_000 }) // 60 seconds
  @Get(':id')
  public getFoo(@Param('id') id: string): Promise<Foo> {
    return this.fooService.getFooById(id);
  }
}

Environment Variables

VariableRequiredTypeDefault
CACHE_HOSTNostring
CACHE_PORTNonumber
CACHE_USERNAMENostring
CACHE_PASSWORDNostring

Redis Module

Provides a connection to Redis via ioredis. It automatically uses credentials configured through the Cache Module.

Usage

import { RedisService } from '@bechara/crux';

@Injectable()
export class FooService {
  constructor(private readonly redisService: RedisService) {}

  public getFoo() {
    const foo = this.redisService.get('FOO');
    if (!foo) {
      throw new InternalServerErrorException('Foo not available');
    }
    return foo;
  }

  public setFoo(params: unknown) {
    const ttl = 5 * 60_000; // 5 minutes
    this.redisService.set('FOO', params, { ttl });
  }
}

Memory Module

Offers a simple in-memory key-value store with support for TTL.

Usage

import { MemoryService } from '@bechara/crux';

@Injectable()
export class FooService {
  constructor(private readonly memoryService: MemoryService) {}

  public getFoo() {
    const foo = this.memoryService.get('FOO');
    if (!foo) {
      throw new InternalServerErrorException('Foo not available');
    }
    return foo;
  }

  public setFoo(params: unknown) {
    const ttl = 5 * 60_000; // 5 minutes
    this.memoryService.set('FOO', params, { ttl });
  }
}

Promise Module

Provides utility functions for working with Promises (retrying, deduplication, throttling, etc.). Refer to PromiseService for details.

Usage

import { PromiseService, HttpService } from '@bechara/crux';

@Injectable()
export class FooService {
  constructor(
    private readonly promiseService: PromiseService,
    private readonly httpService: HttpService,
  ) {}

  public async readFooOrTimeout(): Promise<unknown> {
    const timeout = 5000; // 5 seconds
    return this.promiseService.resolveOrTimeout({
      promise: () => this.httpService.get('foo'),
      timeout,
    });
  }

  public async readFooWithRetry(): Promise<unknown> {
    return this.promiseService.retryOnRejection({
      method: () => this.httpService.get('foo'),
      retries: 5,
      timeout: 120_000, // 2 minutes
      delay: 500,       // 500 ms
    });
  }
}

Metric Module

Collects metrics using Prometheus. Metrics can be scraped at the /metrics endpoint.

Usage

Inject MetricService to create custom counters, gauges, histograms, or summaries:

import { Histogram, MetricService } from '@bechara/crux';

@Injectable()
export class FooService {
  constructor(private readonly metricService: MetricService) {
    this.setupMetrics();
  }

  private setupMetrics(): void {
    this.metricService.getHistogram('foo_size', {
      help: 'Size of foo.',
      labelNames: ['foo', 'bar'],
      buckets: [1, 3, 5, 8, 13],
    });
  }

  public readFoo() {
    const histogram = this.metricService.getHistogram('foo_size');
    // ...
  }
}

Log Module

Provides a logging service with predefined severity levels. Messages are broadcast to all configured transports, which decide whether to publish them based on their own configuration.

Usage

import { LogService } from '@bechara/crux';

@Injectable()
export class FooService {
  constructor(
    private readonly fooRepository: FooRepository,
    private readonly logService: LogService,
  ) {}

  public async readFooById(id: number) {
    this.logService.debug(`Reading foo with ID ${id}`);

    try {
      const foo = await this.fooRepository.readById(id);
      this.logService.notice(`Successfully read foo with ID ${id}`);
      return foo;
    } catch (error) {
      this.logService.error(`Failed to read foo`, error, { id });
      throw new InternalServerErrorException();
    }
  }
}

Call Signatures

Logging methods accept any combination of strings, Error objects, or plain objects:

this.logService.error('Something went wrong');
this.logService.error('Something went wrong', new Error('Log example'));
this.logService.error(new Error('Log example'), { key: 'value' });
this.logService.error('Error message', new Error('Log example'), { key: 'value' });
// ...and so on

Transporters

Two transports are built in: Console and Loki.

Console Transport

Enabled by default. Controlled by:

VariableRequiredTypeDefault
CONSOLE_SEVERITYNostringtrace if NODE_ENV=local; warning otherwise

Loki Transport

Publishes logs to Loki via its API. To enable, set LOKI_URL:

VariableRequiredTypeDefault
LOKI_URLYesstring
LOKI_USERNAMENostring
LOKI_PASSWORDNostring
LOKI_SEVERITYNostringdebug

Trace Module

Implements distributed tracing using OpenTelemetry with B3 header propagation. It automatically creates spans for inbound HTTP requests and outbound HTTP calls. You can also create custom spans using startSpan() from TraceService.

Usage

import { TraceService } from '@bechara/crux';

@Injectable()
export class FooService {
  constructor(private readonly traceService: TraceService) {}

  public readFoo(): Foo {
    const span = this.traceService.startSpan('Reading Foo');
    // ...
    span.close();
  }
}

ORM Module

Adds ORM support using MikroORM, providing schema synchronization and a repository pattern.

Environment Variables

Add relevant connection variables (e.g., for MySQL or PostgreSQL) to your .env:

ORM_TYPE='mysql'
ORM_HOST='localhost'
ORM_PORT=3306
ORM_USERNAME='root'
ORM_PASSWORD=''
ORM_DATABASE='test'

# SSL options
ORM_SERVER_CA=''
ORM_CLIENT_CERTIFICATE=''
ORM_CLIENT_KEY=''

Registration

import {
  AppEnvironment,
  AppModule,
  OrmConfig,
  OrmModule,
  PostgresSqlDriver,
} from '@bechara/crux';

void AppModule.boot({
  configs: [OrmConfig],
  imports: [
    OrmModule.registerAsync({
      inject: [OrmConfig],
      useFactory: (ormConfig: OrmConfig) => ({
        driver: PostgresSqlDriver,
        host: ormConfig.ORM_HOST,
        port: ormConfig.ORM_PORT,
        dbName: ormConfig.ORM_DATABASE,
        user: ormConfig.ORM_USERNAME,
        password: ormConfig.ORM_PASSWORD,
        pool: { min: 1, max: 25 },
        sync: {
          auto: true,
          controller: true,
          safe: ormConfig.NODE_ENV === AppEnvironment.PRODUCTION,
        },
        driverOptions: {
          connection: {
            ssl: {
              ca: Buffer.from(ormConfig.ORM_SERVER_CA, 'base64'),
              cert: Buffer.from(ormConfig.ORM_CLIENT_CERTIFICATE, 'base64'),
              key: Buffer.from(ormConfig.ORM_CLIENT_KEY, 'base64'),
            },
          },
        },
      }),
    }),
  ],
  providers: [OrmConfig],
  exports: [OrmConfig, OrmModule],
});

If you prefer, you can replace OrmConfig with your own configuration class and secrets.

Creating an Entity

Refer to the MikroORM docs on defining entities for detailed guidance.

Creating a Repository

Extend the built-in abstract repository for additional ORM capabilities:

import {
  EntityManager,
  EntityName,
  OrmRepository,
  Repository,
} from '@bechara/crux';
import { User } from './user.entity';

@Repository(User)
export class UserRepository extends OrmRepository<User> {
  constructor(
    protected readonly entityManager: EntityManager,
    protected readonly entityName: EntityName<User>,
  ) {
    super(entityManager, entityName, {
      defaultUniqueKey: ['name', 'surname'],
    });
  }
}

Creating a Controller

Create a controller that injects your repository to handle HTTP requests. For example, a CRUD controller:

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Patch,
  Post,
  Put,
  Query,
  OrmPageDto,
} from '@bechara/crux';
import { UserRepository } from './user.repository';
import { UserCreateDto, UserReadDto, UserUpdateDto } from './user.dto';
import { User } from './user.entity';

@Controller('user')
export class UserController {
  constructor(private readonly userRepository: UserRepository) {}

  @Get()
  public async get(@Query() query: UserReadDto): Promise<OrmPageDto<User>> {
    return this.userRepository.readPaginatedBy(query);
  }

  @Get(':id')
  public async getById(@Param('id') id: string): Promise<User> {
    return this.userRepository.readByIdOrFail(id);
  }

  @Post()
  public async post(@Body() body: UserCreateDto): Promise<User> {
    return this.userRepository.createOne(body);
  }

  @Put()
  public async put(@Body() body: UserCreateDto): Promise<User> {
    return this.userRepository.upsertOne(body);
  }

  @Put(':id')
  public async putById(
    @Param('id') id: string,
    @Body() body: UserCreateDto,
  ): Promise<User> {
    return this.userRepository.updateById(id, body);
  }

  @Patch(':id')
  public async patchById(
    @Param('id') id: string,
    @Body() body: UserUpdateDto,
  ): Promise<User> {
    return this.userRepository.updateById(id, body);
  }

  @Delete(':id')
  public async deleteById(@Param('id') id: string): Promise<User> {
    return this.userRepository.deleteById(id);
  }
}
6.1.0

6 months ago

6.1.2

5 months ago

6.1.1

6 months ago

6.0.0-alpha.1

9 months ago

6.0.0-alpha.2

6 months ago

6.2.1

5 months ago

6.2.0

5 months ago

5.9.9

12 months ago

5.9.8

12 months ago

5.9.7

12 months ago

6.0.1

6 months ago

6.0.0

6 months ago

6.0.3

6 months ago

6.0.2

6 months ago

6.0.4

6 months ago

5.9.10

10 months ago

5.9.11

10 months ago

5.9.12

9 months ago

5.9.13

9 months ago

5.9.6

1 year ago

5.9.5

1 year ago

5.9.4

1 year ago

5.9.3

1 year ago

5.9.2

1 year ago

5.9.1

1 year ago

5.9.0

1 year ago

5.8.2

1 year ago

5.8.1

1 year ago

5.8.0

1 year ago

5.7.5

1 year ago

5.7.4

1 year ago

5.7.3

1 year ago

5.7.2

2 years ago

5.7.1

2 years ago

5.7.0

2 years ago

5.6.1

2 years ago

5.6.0

2 years ago

5.5.9

2 years ago

5.5.8

2 years ago

5.5.7

2 years ago

5.5.6

2 years ago

5.5.5

2 years ago

5.5.4

2 years ago

5.5.3

2 years ago

5.5.2

2 years ago

5.5.1

2 years ago

5.5.0

2 years ago

5.4.2

2 years ago

5.4.1

2 years ago

5.4.0

2 years ago

5.3.7

2 years ago

5.3.6

2 years ago

5.3.5

2 years ago

5.3.4

2 years ago

5.3.3

2 years ago

5.3.2

2 years ago

4.37.2

2 years ago

5.3.1

2 years ago

4.37.1

2 years ago

5.3.0

2 years ago

5.1.2

2 years ago

4.37.0

2 years ago

5.1.1

2 years ago

4.36.11

2 years ago

5.1.0

2 years ago

4.36.13

2 years ago

4.36.12

2 years ago

5.3.0-beta.2

2 years ago

5.3.0-beta.1

2 years ago

4.38.2

2 years ago

5.2.3

2 years ago

5.0.5

2 years ago

4.38.1

2 years ago

5.2.2

2 years ago

5.0.4

2 years ago

4.38.0

2 years ago

5.2.1

2 years ago

5.0.3

2 years ago

5.2.0

2 years ago

5.0.2

2 years ago

5.0.1

2 years ago

4.38.5

2 years ago

5.0.0

2 years ago

4.38.4

2 years ago

4.38.3

2 years ago

4.36.10

2 years ago

4.36.9

3 years ago

4.36.4

3 years ago

4.36.3

3 years ago

4.36.2

3 years ago

4.36.1

3 years ago

4.36.8

3 years ago

4.36.7

3 years ago

4.36.6

3 years ago

4.36.5

3 years ago

4.33.7

3 years ago

4.33.6

3 years ago

4.31.8

3 years ago

4.35.3

3 years ago

4.33.5

3 years ago

4.31.7

3 years ago

4.35.2

3 years ago

4.33.4

3 years ago

4.31.6

3 years ago

4.33.9

3 years ago

4.33.8

3 years ago

4.34.1-beta.1

3 years ago

4.35.1

3 years ago

4.33.3

3 years ago

4.31.5

3 years ago

4.35.0

3 years ago

4.33.2

3 years ago

4.31.4

3 years ago

4.33.1

3 years ago

4.31.3

3 years ago

4.33.0

3 years ago

4.31.2

3 years ago

4.33.11

3 years ago

4.33.10

3 years ago

4.32.0

3 years ago

4.36.0

3 years ago

4.34.2

3 years ago

4.34.1

3 years ago

4.34.0

3 years ago

4.32.1

3 years ago

4.26.0

3 years ago

4.25.1

3 years ago

4.27.0

3 years ago

4.26.1

3 years ago

4.28.0

3 years ago

4.26.2

3 years ago

4.29.0

3 years ago

4.26.3

3 years ago

4.31.1

3 years ago

4.30.2

3 years ago

4.31.0

3 years ago

4.30.1

3 years ago

4.30.0

3 years ago

4.30.6

3 years ago

4.30.5

3 years ago

4.30.4

3 years ago

4.30.3

3 years ago

4.25.0

3 years ago

4.24.4

3 years ago

4.24.3

3 years ago

4.24.2

3 years ago

4.24.1

3 years ago

4.24.0

3 years ago

4.23.1

3 years ago