Skip to content

Latest commit

 

History

1,537 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

node-service-template

Versioning

Please bump the version in package.json and add description of changes into CHANGELOG.md when making changes.

When synchronizing services built with the template with the upstream changes, please bump the version in package.json to reflect the step up to which the changes are synchronized.

Overview

node-service-template provides a "battery-included" starter template for building enterprise Node.js webservices.

It comes with the following out-of-the-box:

  • fastify as a basis for the general web application skeleton;
  • Modular, domain-driven structure that encourages separation of concerns;
  • Server/app separation, for convenient bootstrapping in e2e tests;
  • Global error handler;
  • JSON-based, single line standardized logging;
  • Automatic population of req.id for incoming requests based on x-request-id header, or generation of new UUID if none is set, for the purposes of distributed tracing.

Mechanisms:

Scaffolding:

Basic building block examples:

Plugins:

Scripts:

Service template also comes with a curated set of plugins installed:

  • @fastify/helmet (security headers)
  • @fastify/swagger (OpenAPI specification generation)
  • @fastify/awilix (dependency injection)
  • @fastify/schedule (scheduling background jobs)
  • @fastify/auth (authentication)
  • @scalar/fastify-api-reference (OpenAPI specification website)
  • fastify-graceful-shutdown (handling SEGTERM gracefully)
  • fastify-no-icon (avoiding warnings when sending GET calls via browser)
  • @lokalise/fastify-extras -> metricsPlugin (exposing Prometheus metrics)
  • @lokalise/fastify-extras -> requestContextProviderPlugin (storing requestId in AsyncLocalStorage and populating requestContext on request)
  • @lokalise/fastify-extras -> OpenTelemetryTransactionManager (creating custom OpenTelemetry spans for background jobs)
  • @lokalise/fastify-extras -> bugsnagPlugin (reporting errors to BugSnag)
  • @lokalise/fastify-extras -> amplitudePlugin (tracking events in Amplitude)
  • @lokalise/fastify-extras -> commonHealthcheckPlugin (registering public healthchecks)

Note that some of the fastify-extras plugins may not be relevant for you (e. g. if you are not using Prometheus, New Relic or Bugsnag). In that case you should remove the plugins and delete everything that breaks when you attempt to build the project.

We recommend you to create your own @your-org/fastify-extras package and create your own mix of vendor plugins that are relevant for the technological stack of your organization, and replace @lokalise/fastify-extras with it.

Getting Started

  1. Make sure your node version is compatible with the requirements in package.json. We are working with node >= 24 and recommend using a version manager, such as nvm, to manage multiple Node versions on your device if needed.

  2. Install pnpm (this project uses pnpm as its package manager — the exact version is pinned in the devEngines.packageManager field of package.json, and a mismatch hard-fails). Corepack is deliberately not used; install pnpm directly:

    npm install -g pnpm@11.25.0
  3. Install all project dependencies:

    pnpm install

    This is a pnpm workspace. The service lives at the repository root and the publishable API contracts live under packages/api-contracts. The service consumes the contracts via @node-service-template/api-contracts (workspace:*).

  4. Copy the .env.default file to a new .env file. You can do this with the following npm script:

    node --run copy:config
  5. Launch all the infrastructural dependencies locally:

    docker compose up -d

    This starts PostgreSQL, Redis, RabbitMQ, and fauxqs (local SQS/SNS emulator).

    In tests, fauxqs runs as an embedded library (no Docker required) — see test/FauxqsHelper.ts.

  6. Run migrations to synchronize your database schema with defined models:

    node --run db:apply-migrations
  7. To run application:

    node --run start:dev

    NOTE: By default all calls to the node-template app will require a valid JWT token, hence authentication errors when running the application are expected if you haven't yet followed the steps in Create jwt for dev usage.

Drizzle Migrations

We use Drizzle as convenient mechanism for building queries.

In order to automatically generate a new migration,

  1. Edit an existing schema file or add a new one to src/db/schema;
  2. Run node --run db:generate-migrations -- --name {migrationName}, where customName is a short message describing your change separated by underscores (_);
  3. Run node --run db:apply-migrations to apply your new migration.

In case you need to remove a previously generated migration,

  • Run node --run db:drop-migrations. It is recommended to use this command instead of deleting files manually, as it could break drizzle-kit (see here).

Tests

Before running your tests, make sure to run

node --run test:migrate

To initialize your test database and/or apply your latest schema changes.

OpenAPI specification

You can access OpenAPI specification of your application, while it is running, by opening /documentation

Schema validation

Validation uses zod 4.5. Schemas that are parsed at runtime (API contracts, message payloads, job payloads, database row schemas) are wrapped in z.compile(), which returns a clone backed by an ahead-of-time compiled parser. Valid input takes the compiled path; invalid input falls back to the regular parser, so error reporting and generated JSON Schema are unchanged.

Two rules when adding schemas:

  • Compile the final schema. .extend(), .omit(), .refine() and friends applied to a compiled schema return an uncompiled one.
  • Skip z.compile() for schemas parsed once per process (environment config, CLI arguments). The compilation step costs more than the single parse saves.

The alternative is the global import 'zod/compile' side-effect import, which compiles every schema constructed afterwards on first parse. This template uses explicit calls instead, so compilation does not depend on module evaluation order and behaves the same under vitest as it does in src/server.ts.

Error handling

Errors are modelled with @lokalise/errors. Client-facing errors are PublicError subclasses, internal ones InternalError subclasses. Every class carries a literal code, and public errors add an ErrorType that determines the HTTP status.

  • Public error definitions (definePublicError()) live in the api-contracts package, next to the contracts that reference them via mergeErrorSchemasByStatusCode(). The error payload is therefore part of the contract and generated OpenAPI spec, and clients can parse it with the same schema.
  • The owning module binds each definition to a class, e.g. UserNotFoundError.ts, and throws it from its services. Match errors with the static isInstance() guard, not instanceof.
  • The global error handler turns a PublicError into error.httpStatusCode plus error.toPayload(), and reports and logs anything mapped to a 5xx.

OpenTelemetry instrumentation

There is an OpenTelemetry integration included, using the gRPC exporter. See environment variable configuration for the details on configuring it.

It should work out-of-the-box for all incoming HTTP requests, as long as correct exporter URL is configured.

Create jwt for dev usage

You have multiple options to ease your development:

  1. Comment onRequest hook for JWT verification here
  2. Generate a valid JWT with provided generateJwt script

Generate JWT script

  • Script requires public-private key pair encrypted with RS256 algorithm:

    mkdir -p ./scripts/keys && ssh-keygen -t rsa -b 4096 -m PEM -f ./scripts/keys/jwtRS256.key
    # Don't add passphrase
    openssl rsa -in ./scripts/keys/jwtRS256.key -pubout -outform PEM -out ./scripts/keys/jwtRS256.key.pub
  • Default file names are jwtRS256.key for private key and jwtRS256.key.pub for public key

  • Run JWT generate script:

    node --run jwt:generate
  • Your public key and token will be printed to console to make things easier. This is an example output:

    Public key:
    -----BEGIN PUBLIC KEY-----||MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAt+2fSaw+mjbQpbPYcGv7||A0zs+P1yuCcM4LzTRpMmtXCoxCg3hwVZUM9HoxM4NxSga5A/jdHDhn1qEgQF38cX||N/wG+cRx1YfxDV2fSYxO9ouh+0J+uJaAXs0kWM0oAojrcMI4q1PcTeCFBvKDR+ei||Nu5auiRe7yrBfQTqsSmvEDRlnhUnF24CnNQPuzeN4Qe8LmcXuwimEyAi9Tf7hXBN||H31j+jnUfIq9Yy7EsbmZhW3aEmQlmR6RY/9g+IEzbpmBoYznYsxmvtODpay7n+NY||zWtOdtJC9eKDaOs3wYjDR0G9uHe00ZIBiNfZWRGfTS/3+Sl9Yx8UesVpg8WqbkxC||LwAABtA5/WiKYxp3wsx4Qu9ooZwiE6tlgsb3hZAeusNODQ+rZsoiCowxNNfZ0fvj||veaBxDz7xB4t9fST9rsBJewPna3oFMlEPxigyv4ogFo60V9Ds6e8GHuYevSUeS34||BimjE2T0uE+HYatEmUY5tHRhTgBKP+Ty9dY2I9dpPDSl/nM63PmmbqSr7DIBreh4||pr3LwEPtffpaAY/YdQ0ypAVc7xuQMreTlzEsAFzbwnfI5eTT9oxZHBb1ulrnei1e||w6yxZ93j2UmCnaXPrTWsqyr/tXH4/sfLjqkY7Upj/zl7i0FlDAxtdv3qGg5Ozpj/||8OXPuK2d9Kv7C58uaVhO5bsCAwEAAQ==||-----END PUBLIC KEY-----||
    JWT:
    eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbSI6InZhbHVlIiwiaWF0IjoxNjc2OTU4MDgwfQ.ssp6cX8Juv5a56VB-w4Nhdi1XmEyTsbPc9zBre2XylHnXvGdkx38GYYXAP9UDUAw4lkU7GE0FA8wlMitB3iFPKHLDWU8d-E1W0fV6GXAAngMMRrZeRCREYGx3FchEj8ufY4_7i4jGdA2ph8WOgrTqrpjJRjYYuClFYuCgH8QsRIeCnLo_UU3AhmqJ1FnUUXYK4lpk5ssvuUR3OZR4nexNZJEsIbL_584_FHc7duui7WMQ9fvBInNG4FRu3d2ZPC9RejAlV5sRk8H0HpLORM54h4SfeUXiapNnu7Td_in_3YmhdwBVugnxDdATUBMjHbSSjxn0UWCFD2whTxFOFS6rICGWhUMViRQ9fSljwiEaAkYGwRSOKB0McYXucYmxvjyBJo2ngFEGuymJi7Ow6cjNfri6BoiCoZwQfkvAzsrTYzR4lGV7lG7o4GpX4aoUTwnndvDtvxNzHtb4ssilfFFnvRQC63v9ybIRkIBhm9GaSepoPDw9lrblImnS13-WEPWy2l5_wIeYZUSPvlPIS3SV17b9ohGoNzk-axmB5QG1PvLYpZ2_t0z7h5od2vw5ZTPNOQ-RhNSu28REd4Mp0xHySYsn0ukf4kZHPUoGbMIuIMg6WhVTsz7V4n0nd1iPIjBfJjWM5dDSZfQvg4whwO1jeaE4BXxpjeiFqxf_tOT1QM
    Verified payload:
    {"claim":"value","iat":1676958080}
    
  • Copy your public key to JWT_PUBLIC_KEY in your .env file. Make sure to replace occurrences of || in your key with new lines

  • Restart the application to load the new public key:

    node --run start:dev
  • Use your token to authenticate through bearer authentication in your requests

Validate env var doc script

  • Script will get all environment variables used in config.ts and validate that all are documented in docs

    node --run docs:validate

On successful validation, the script will print a message to the console:

✅ All environment variables are documented!

If any variable is not documented you will see a list of undocumented ones:

❌ Missing documentation for the following environment variables:
- VARIABLE_NAME

Troubleshooting

  • If you are running a service in a monorepo setup, it is launched in the background and you want to always force closing the service before attempting to restart, you can use node --run free-ports, which will kill an application running on the predefined port (in an OS-independent way).

CLI Commands

To create a new CLI command, create a new file in the scripts/cmd directory. The file should be self-executable and wrap its logic with cliCommandWrapper, which boots the app, parses and validates arguments, and handles logging, graceful shutdown, and exit codes.

To use arguments in your command, provide a zod schema to cliCommandWrapper(). CLI flags are derived from the schema itself — string, boolean, repeatable (array) and optional flags are supported, and the schema may end in .transform()/.pipe() to reshape flat flags into a nested args object. See docs/cli-commands.md for the full description of what is supported.

Create a new command in the scripts section of package.json:

"scripts": {
  "cmd:getUserImportJobs:dev": "cross-env NODE_ENV=development tsx --env-file=.env scripts/cmd/getUserImportJobs.ts",
  "cmd:getUserImportJobs:prod": "node scripts/cmd/getUserImportJobs.js",
}

!!! Be aware of extensions and node / typescript execution commands in command paths, as development environment differs from non-development.

To run a command locally, use node --run {npmScriptName} -- {arguments}. Example:

node --run cmd:getUserImportJobs -- --queue=active

To run a command in a run-command pipeline, use {npmScriptName} -- {arguments} as a command argument. Example:

cmd:getUserImportJobs -- --queue=active

About

Batteries-included starter template for Node.js backend services

Topics

Resources

Code of conduct

Contributing

Stars

379 stars

Watchers

17 watching

Forks

Releases

Packages

Used by

Contributors

Languages