@earnest-labs/microservice-chassis
TypeScript icon, indicating that this package has built-in type declarations

4.2.454 • Public • Published

npm version

Microservice-Chassis

An opinionated framework for building and running microservices. Heavily plugin oriented, with a goal of minimizing repitition and boilerplate in other projects.

Opinions

I) Tooling should work out of the box. You shouldn't have to spend time figuring out: that nyc simply doesn't work with ES Modules; that jest has trouble resolving dynamic imports with typescript; how to get unit tests running; how to get typescript configured "just so"; how to get code coverage reports

II) Frameworks and tools should be as up-to-date with latest standards and specs as possible.

III) Boilerplate should be concentrated rather than repeated.

IV) In the spirit of being as up-to-date as possible, If the industry is moving to ES Modules, we should just use them exclusively, and our tools should help make that happen.

V) Frameworks and tools should be as open to extension as possible, and as closed to modification as possible. That is, the API we provide should be as stable as we can make it, while allowing other packages to extend it. In OO circles, this is called the open-closed principle.

VI) Learning curves should have minimal slope. You should be able to use this framework without much study beyond learning typescript, express, and reading this README.

Features

Typescript "Just So"

We provide a sensible, very modern tsconfig.json so you're not wasting your time trying to get it working. It's in ES Module mode, transpiles to ES2022, and has source mapping enabled for easy debugging.

npx chassis-tsc

As of version 2.0, we enable strictNullChecks by default. You can disable this again by setting the environment variable CHASSIS_DISABLE_STRICT_NULL_CHECKS to any non-empty value when running npx chassis-tsc. This choice makes type matching considerably more strict, which in turn forces us to think more deeply about our types and ultimately avoid classes of bugs related to accepting undefined or null when we neither intended nor expected to.

Test Execution and Coverage Scripts

Write nodejs test modules in Typescript; we'll run them and give you coverage reports. Want to ignore a file when calculating coverage? Just include the comment // c8 ignore file in the file. this is a chassis-provided extension to c8.

You can adjust coverage levels by setting c8 options in your package.json, like this:

{
  "c8": {
    "lines": 80,
    "functions": 90,
    "branches": 90
  }
}

You can also adjust coverage levels by setting the C8_FLAGS environment variable.

% C8_FLAGS="--lines 80" npx chassis-test

See here for more details on c8.

See here for more details on writing nodejs tests.

npx chassis-test

Plugins

Microservice-Chassis scans your application for plugins, and runs those plugins, so that your application code consists entirely of plugins. You do not need any startup boilerplate. To create a plugin, simply write a file conforming to one of the filename patterns: *.chassis-plugin.js or chassis-plugin.js, and make sure it exports an object named plugin conforming to the interface @earnest-labs/microservice-chassis/Plugin. You can use npx chassis-new-plugin {name} to create a plugin to start from.

Logging

Microservice-Chassis provides a Winston logger as part of the context object provided to your plugin. See PluginContext.ts.

This logger is automatically configured to output colorized, timestamped log messages when running locally, or a stream of JSON objects when running in a server environment. The logic to determine the logging environment is housed in logging.ts#chassisLoggingEnv(). It chooses the first defined environment variable in this list: CHASSIS_LOGGING_ENV, CHASSIS_ENV, NODE_ENV. If the logging environment is undefined, or one of development, local, or console, logging will use the localFormat format; otherwise it will use the cloudFormat layout.

Microservice-Chassis also provides Lefay (since Morgan ended up being unable to log requests in a splunk-friendly json format), which acts as Express middleware to perform request logging. You shouldn't need to think about this much - just be aware that it's a thing that happens.

The logger also filters for personally identifiable information (PII) and other sensitive data, so that you don't accidentally log secrets. You can force logging of fields that would otherwise be blocked by applying an allow list, and block additional fields that would otherwise by allowed by applying a block list. These are applied by setting CHASSIS_LOGGING_ALLOW_LIST and CHASSIS_LOGGING_BLOCK_LIST. You can set them to a :-delimited list of strings, each representing a specific key name that you want to allow or block.

For example, if you want to log zip and mobile even though they are considered PII fields, you can set CHASSIS_LOGGING_ALLOW_LIST to zip:mobile.

Express

Generally, when we think of microservices, we think of REST APIs or perhaps Graphql APIs. Microservice-Chassis listens on one HTTP port, or in Express terminology, application, which is exposed as the application member of the PluginContext object. This port is intended to be publicly accessible and is visible as port 3000.

Graceful Shutdown

When an unrecoverable event is detected by the application (SIGINT, SIGTERM, or unhandled rejection), the chassis will terminate after making a best effort to notify all plugins that the application is terminating by calling each plugin's shutdown method. The shutdown method will be wrapped in a timeout whose duration is set by context.env.CHASSIS_TIMEOUT_SHUTDOWN_PLUGIN, defined in milliseconds with a default of 2000. The entire notification process will be wrapped in another timeout, whose duration is set by context.env.CHASSIS_TIMEOUT_SHUTDOWN_ALL_PLUGINS, defined in milliseconds, with a default of 3000.

In other words, if your microservice receives SIGINT, SIGTERM, or fails to handle a rejection, the chassis will call all of your plugins' shutdown methods, each with a timeout of context.env.CHASSIS_TIMEOUT_SHUTDOWN_PLUGIN, and with a collective timeout of context.env.CHASSIS_TIMEOUT_SHUTDOWN_ALL_PLUGINS, and then call process.exit.

There is no way to recover, and this is by design. Once you have encountered an unrecoverable event, the instance where this has happened is tainted. The most you can do is try to complete or cancel whatever is in progress, and hope that your environment (e.g., kubernetes) is configured to start a new instance of your service. The only one of these where you could conceivably recover is an unhandled rejection, and if that's what you are encountering, you should handle the rejection.

Swagger

Swagger is a user interface that supports reading and exercising an openapi specification for an API.

microservice-chassis provides a swagger user interface plugin built on swagger-ui and swagger-jsdoc.

To provide openapi documentation for a given endpoint, you should write an openapi jsdoc comment just before it, like this:

/**
 * @openapi
 * /ping:
 *   servers: ["{{application}}"]
 *   get:
 *     description: Server health check
 *     responses:
 *       200:
 *         description: Okay.
 */
await context.application.get("/ping", serverStatus(name, version));

In the jsdoc comment above, we are utilizing an extension provided by microservice-chassis. Because microservice-chassis has two express servers, we occasionally need to indicate which servers a given endpoint is accessible on. In the case of /ping, at the time of this writing, we allow /ping from both servers, so we can specify both servers as options in the servers: array.

Please see the microservice-chassis swagger plugin source code for details on how this extension translates into a standard openapi specification.

Graphql

Graphql integration is tested regularly, but is in a separate plugin, located at http://github.com/earnest-labs/chassis-plugin-graphql/ or npm install @earnest-labs/chassis-plugin-graphql

Getting Started

  1. Make sure prerequisites are installed:
# either:
npm install -g jq
# or:
brew install jq fswatch

rsync (installed by default on OSX)

  1. Create a package.json:
npm init
  1. Install microservice-chassis
npm install --save @earnest-labs/microservice-chassis
  1. Run your service:
npx chassis-start
Ctrl+C to kill
  1. Create a plugin:
npx chassis-new-plugin sample
  1. Test your service:
npx chassis-test

## to watch the file system and re-run your tests when you save a
## file.
npx chassis-test-watch

## to watch the file system and re-run only tests or suites tagged
## with 'only', e.g., `describe.only(...)` or `it.only(...)`:
TEST_OPTIONS=--test-only npx chassis-test-watch
  1. Run your service:
npx chassis-tsc
npx chassis-start

In a separate terminal:

curl http://localhost:3000/sample
{"name":"sample", "version": "1.0.0"}

Setting tsconfig.json options via environment variables

tsconfig.json defaults are meant to be widely adopted. However, for backwards compatibility, some configs can be overwritten via specific environment variables:

  • CHASSIS_DISABLE_STRICT_BIND_CALL_APPLY turns strictBindCallApply off.
  • CHASSIS_DISABLE_STRICT_NULL_CHECKS turns strictNullChecks off.
  • CHASSIS_ENABLE_JSX sets jsx to react.

Tips & Tricks

Focusing on Specific Tests

When writing tests, you may want to run a specific describe or it block without running the entire suite. You can do this by using .only or {only: true}. This will instruct the test runner to only execute that particular block. https://nodejs.org/api/test.html#only-tests

Note: When using npx chassis-test --test-only, ensure .only is applied to both describe blocks and their sub it tests. This is necessary because --test-only only runs tests marked with .only. Unlike some testing frameworks, if .only is applied solely to the describe block, not all tests within that block will run. Similarly, if .only is applied only to an it test without being applied to its parent describe block, the test will not run.

describe.only('My test suite', () => {
  // This suite will be the only one running
});

it.only('My test case', () => {
  // This test case will be the only one running
});

You can also use the {only: true} option:

describe('My test suite', {only: true}, () => {
  // This suite will be the only one running
});

it('My test case', {only: true}, () => {
  // This test case will be the only one running
});

Running Specific Tests with Chassis-Test

You can also use npx chassis-test with the --test-only or --test-name-pattern options to run specific tests. The --test-only option will run only the tests that are marked with .only or {only: true}. The --test-name-pattern option allows you to specify a regex pattern to match the tests you want to run.

npx chassis-test --test-only
npx chassis-test --test-name-pattern="regex-matching-tests-i-want-to-run"

Decision Record

Pinning New Relic to 11.14.0

Since 4.2

We hope to remove this pin ASAP.

Emitting declarations can be bypassed

Since 4.1

We recently encountered an issue where code in a chassis-based microservice produces this error after updating to latest version of the chassis:

error TS4023: Exported variable '{variable}' has or is using name 'ArrayOptions' from external module "stream" but cannot be named.

ArrayOptions is indeed present in node:stream, but is not exported, so there does not appear to be a way to re-export it in the context in question.

Since a microservice shouldn't need to export types, and because in this case the error is emitted from helpers for testing (some generics-generated stubs), we feel it is expedient and not very risky to disable emission of type declarations from the service. This is something we will need to revisit because the problematic generics are something we would like to migrate into an npm package. At that time, emitting the type declaration will be necessary.

In any case, to disable declarations, set CHASSIS_DISABLE_DECLARATIONS=1 in your environment.

Graphql npm package removed as dependency

Since 4.0

Prior to 4.0, the graphql npm package was included in package.json and package-lock.json. This was an error, as we intended from the start to enable graphql only via the aforementioned plugin.

logger optional in findPlugins.ts

since 3.2

While trying to make other plugins use strict-null-checks, it has become apparent that the chassis requiring a logger in many of its utility functions is a nuisance. We're starting with findPlugins.ts, but it is likely that other utility functions that currently accept a logger will soon accept undefined in its place, and simply not log when they aren't given a logger.

npx chassis-pretty ignores dist/ folder

since 3.1

npx chassis-pretty now checks to see if your .prettierignore file contains ^dist$, and if not (or if you don't have a .prettierignore), adds it.

strictBindCallApply by default

since 3.0 breaking change

As of version 3.0, we enable strictBindCallApply by default. You can disable this again by setting the environment variable CHASSIS_DISABLE_STRICT_BIND_CALL_APPLY to any non-empty value when running npx chassis-tsc. If you do, then the args passed to any fn.bind() will not be type checked.

strictNullChecks by default

since 2.0 breaking change

As of version 2.0, we enable strictNullChecks by default. You can disable this again by setting the environment variable CHASSIS_DISABLE_STRICT_NULL_CHECKS to any non-empty value when running npx chassis-tsc. This choice makes type matching considerably more strict, which in turn forces us to think more deeply about our types and ultimately avoid classes of bugs related to accepting undefined or null when we neither intended nor expected to.

ts-sensitivestring is a peer dependency

since 1.0 breaking change

Typescript gets confused about which version of ts-sensitivestring you're referring to if ts-sensitivestring is installed as a transitive dependency (i.e., via package.json./dependencies). This results in weird type errors. The solution is for every service to install ts-sensitivestring. It would be nice if there was a middle ground in npm land between dependencies and peerDependencies, something like sharedDependencies that would cause the consumer to install a "sharedDependency" at the top level of node_modules like peerDependencies, but not require the consumer's developers to explicitly list the dependency in their package.json other than to resolve conflicts.

npx chassis-start configures node to enable source maps

since 1.0

This was clearly an oversight in prior versions of microservice-chassis. Of course you want source maps, because otherwise you have to look at the compiled javascript to understand stack traces, which is painful.

Registration Order

Please categorize your plugins and put their registration order according to this list:

[-infinity => -100,000) : shared plugins that need to load prior to built-in chassis plugins.

[-100,000 => -10,000) : service-specific plugins that need to load prior to built-in chassis plugins.

[-10,000 => -1000) : chassis plugins

[-1000 => 0) : shared plugins that need to load after chassis plugins but before service-specific plugins

0 : plugins where load order isn't particularly important.

(0 => 10,000] : service-specific plugins

(10,000 => 100,000] : shared plugins that need to load after service-specific plugins

(100,000 => infinity] : chassis plugins that need to load after everything else

Note that this set of ranges is not a hard-and-fast rule, and is not enforced by the chassis. Also, it is perfectly acceptable for multiple plugins to have the same registerOrder value provided they are do not form a dependency chain. I.e., if A doesn't need B, and B doesn't need A, they could both have registerOrder = 0

ES Modules over CommonJS

The industry appears to be heading this way. Therefore, we will use, exclusively, ES Modules, in an attempt to avoid having to port everything in the future.

C8 over NYC

While the industry is heading toward ES Modules, not all of the tooling is quite ready. In particular, NYC provides zero coverage when you turn on "type": "module" in package.json. So until NYC supports ES Module code coverage, we cannot use it. There are supposed workarounds listed here, but the consensus on that thread is that they don't work and you should just use c8 for the time being.

Here is the stackoverflow thread that first tipped us off to c8's existence.

NodeJS test runner and asserts over mocha, chai, and/or jest.

Yes, it's still experimental, but if the javascript engine provides testing, why have an external dependency? We're hedging this bet by using the describe/it API, as it is basically identical to mocha, from a developer standpoint; the only difference is whether you do import {describe,it} from "node:test"

Plugin registration order over convoluted configuration mechanism

Much talk occurred on the chassis team about whether to configure services by overlaying config files, reading from package.json, or some other mechanism. We ultimately decided that this is something that belongs outside of the chassis, at least for now. Instead, we decided to make it easier to use process.env as your configuration source of truth. In particular, we've added a field to the plugin type, registerOrder, which will be used as the sorting key prior to calling the plugin registration function register(). This allows the production of plugins that do things like load passwords from external sources into process.env before those passwords are used by, e.g., a database resource plugin. We believe this approach follows the open/closed principal in that we are allowing extensions to be able to create plugins with deterministic behavior, while not dictating what those plugins do.

Readme

Keywords

none

Package Sidebar

Install

npm i @earnest-labs/microservice-chassis

Weekly Downloads

1,041

Version

4.2.454

License

MIT

Unpacked Size

173 kB

Total Files

99

Last publish

Collaborators

  • earnestlabsci
  • earnest-admin