An opinionated framework for building and running microservices. Heavily plugin oriented, with a goal of minimizing repitition and boilerplate in other projects.
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.
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.
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
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.
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
.
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.
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 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 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
- Make sure prerequisites are installed:
# either:
npm install -g jq
# or:
brew install jq fswatch
rsync
(installed by default on OSX)
- Create a package.json:
npm init
- Install microservice-chassis
npm install --save @earnest-labs/microservice-chassis
- Run your service:
npx chassis-start
Ctrl+C to kill
- Create a plugin:
npx chassis-new-plugin sample
- 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
- Run your service:
npx chassis-tsc
npx chassis-start
In a separate terminal:
curl http://localhost:3000/sample
{"name":"sample", "version": "1.0.0"}
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
turnsstrictBindCallApply
off. -
CHASSIS_DISABLE_STRICT_NULL_CHECKS
turnsstrictNullChecks
off. -
CHASSIS_ENABLE_JSX
setsjsx
toreact
.
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
});
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"
since 5.1
Define a file that will compile into /dist/**/*custom-lefay-decorator.js. Export a const called decorator
, assigned a function that will return an object that will be included under the custom
key in the default chassis access request logs.
Since 4.4
We recently encountered an issue where requests between services were resulting in socket hang-up errors that we suspect is a result of the default HTTP keepAliveTimeout of 5s being too short-lived. We are now overriding this configuration by either allowing an env variable to define the setting, otherwise defaulting the timeout to 60s.
Since 4.2
We hope to remove this pin ASAP.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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"
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.