Express as dependency injection container.
Jimpex is an implementation of Express, one of the most popular web frameworks for Node, using Jimple, a Javascript port of Pimple dependency injection container.
⚠️ If you want to upgrade to v8, please read the migration guide.
import { jimpex } from 'jimpex';
const app = jimpex();
app.set('something', () => new Something());
app.mount('/hello', (req, res) => res.send('Hello World!'));
app.listen(2509, () => {
console.log('The app is running!');
});
That's all: you create the app, register/set your dependencies, and mount your routes.
Alternatively, you could import the class, subclass it, and make use of the "life cycle" methods to configure it:
import { Jimpex } from 'jimpex';
class MyApp extends Jimpex {
boot() {
this.set('something', () => new Something());
app.mount('/hello', (req, res) => res.send('Hello World!'));
}
}
const app = new MyApp();
app.listen(2509, () => {
console.log('The app is running!');
});
The boot
method gets called right from the constructor, when the boot
option is set to true
(its default value), and the idea is for you to use it to add all your customizations (as seen in the example above).
Like boot
, there's another "helper method" that you can use to register your dependencies: _init
; the difference is that _init
gets called from the constructor before the boot
options gets validated. This allows you to create scenarios in which you could register some resources on _init
, disable the call to boot
, overwrite what you set on _init
, and manually call boot
:
class MyApp extends Jimpex {
_init() {
this.set('message', 'Hello Charo!');
}
boot() {
app.mount('/hello', (req, res) => res.send(this.get('message')));
}
}
const app = new MyApp({ boot: false });
app.set('message', 'Hello Pili!');
app.boot();
Now, this may look like an unnecessary feature, but if you consider the possiblity of different entry points based on environment, maybe to enable debugging tools on dev, this becomes really useful.
On Jimpex, you have two sets of "settings": the options, which are in the code when you create the application; and the configuration, which may be related to the environment in which the application is running.
You can read all about the different options in its own documentation, but they're more related to the application capabilities. For example:
- Whether or not to call
boot
. - Whether or not Express should remove the
x-powered-by
header. - Which env var to check for the environment configuration (you'll see more about this in a sec).
And depending on how you defined your application, you have two ways to customize the options.
First, if you used the function, you can just send them as a parameter:
import { jimpex } from 'jimpex';
const app = jimpex({
boot: false,
express: {
disableXPoweredBy: true,
},
config: {
environmentVariable: 'NODE_ENV',
},
});
And yes, you could also use this approach with the class, as both the function and the class constructor share the same parameters, but if you want to already define your options inside your subclass, there's a protected helper for it: _initOptions
.
import { Jimpex } from 'jimpex';
class MyApp extends Jimpex {
_initOptions() {
return {
boot: false,
express: {
disableXPoweredBy: true,
},
config: {
environmentVariable: 'NODE_ENV',
},
};
}
}
The advantage of _initOptions
is not only that you don't need to overwrite the constructor, but that you can still receive the options from the constructor, and Jimpex will automatically deep merge the ones on _initOptions
on top of them.
These are the settings that are more related to the runtime, and that can change depending on the environment. The most basic example of a "configuration setting" would be the port in which the application runs: you could run it on local env in 3000, and on production in 80 (or 443).
While the configuration is more easily managed with external (config) files, you could also send it in the options:
import { jimpex } from 'jimpex';
const app = jimpex({
config: {
default: {
port: 3000,
},
},
});
// or
const app2 = jimpex(
{ someOption: false },
{
port: 3000,
},
);
You can use the option config.default
, or just the second parameter of both the function and the class.
⚠️ If you don't configure theport
and callstart()
, the application will throw an error.
That's the basic usage, but as mentioned before, the configuration is more easily managed with external files: when loading the configuration, which happens when the server starts, it will try to look for a file config/app.config.js
, and if it exists, it will set whatever it (default) exports as the configuration.
So, we could write the configuration from the example above and use it like this:
// config/app.config.js
export default {
port: 3000,
};
// src/index.js
import { jimpex } from 'jimpex';
const app = jimpex();
app.start();
Now, the really great feature about the config files, is that you could extend them and manage which one gets loaded depending on the environment: When loading the configuration, Jimpex will check the value of the environment variable CONFIG
and try to load a config file config/app.config.${CONFIG}.js
. For example:
You could have the default configuration with the port set to 3000
, and a "staging" configuration with the port set to 80
.
// config/app.config.js
export default {
port: 3000,
};
// config/app.config.staging.js
export default {
port: 80,
};
And if CONFIG
is set to staging
when the application starts, the staging configuration will be loaded.
Finally, you can access the configuration settings with the config
service:
const port = app.get('config').get('port');
You can read more about the options for the configuration service in the options documentation, but it's basically an implementation of
@homer0/simple-config
.
app.listen(2509, () => {
console.log('The app is running!');
});
Just like any other Node server, you can use the listen
method and specify a port and a callback.
Now, if you you already have a port
set on the configuration, you could use the start
method; it works just like listen but it only receives a callback:
app.start(() => {
console.log('The app is running!');
});
And finally, you can also stop the application by calling...
app.stop();
// Done, the app is not longer running.
HTTPS is enabled via configuration, not options, just like the application port:
// config/app.config.js
export default {
port: 2509,
https: {
enabled: true,
credentials: {
cert: '...cert-file',
key: '...key-file',
},
},
};
By default, Jimpex will look for those files relative to the project root directory, but you can change so it will look on a path relative to the directory where the application executable is located by setting https.credentials.onHome
to false
.
To enable HTTP/2, you MUST enable HTTPS first, and then just add a flag in the configuration:
// config/app.config.js
export default {
port: 2509,
https: {
enabled: true,
credentials: {
cert: '...cert-file',
key: '...key-file',
},
},
http2: {
enabled: true,
},
};
Under the hood, Jimpex uses Spdy for the HTTP/2 support, and Spdy has custom options you can send in order to define how it will work; you can send options to Spdy by adding a spdy
key inside the http2
object:
// config/app.config.js
export default {
port: 2509,
https: {
enabled: true,
credentials: {
cert: '...cert-file',
key: '...key-file',
},
},
http2: {
enabled: true,
spdy: {
'x-forwarded-for': '127.0.0.1',
},
},
};
This is the most-core functionality that Jimple, and Jimpex, provide. You can easily create providers for services/resources that you want to use in your application:
// src/my-service.js
import { provider } from 'jimpex';
export class MyService {
constructor(config) {
this.config = config;
}
getMeThePort() {
return this.config.get('port');
}
}
export const myService = provider((app) => {
app.set('myService', () => new MyService(app.get('config')));
});
You could just export the provider, but I believe is a good practice to export both, in case another part of your app wants to extend the class and overwrite the service on the container.
The, on the app, you would simple register
the provider:
import { Jimpex } from 'jimpex';
import { myService } from './my-service';
class MyApp extends Jimpex {
boot() {
this.register(myService);
}
}
Done, your service is now available in the container.
Jimpex already comes with a few built-in service providers ready to be used, and you can read about them on the services document.
Since the version of Jimple that Jimpex uses under the hood is @homer0/jimple
, my custom "fork", you can also create "configureable providers":
// src/my-service.js
import { providerCreator } from 'jimpex';
export class MyService {
constructor(config) {
this.config = config;
}
getMeThePort() {
return this.config.get('port');
}
}
export const myService = providerCreator((options = {}) => (app) => {
const { serviceName = 'myService' } = options;
app.set(serviceName, () => new MyService(app.get('config')));
});
If you are thinking this is just a HOF, you are wrong. The "magic" of the creator version is that it can be used as a provider and/or as a function:
import { Jimpex } from 'jimpex';
import { myService } from './my-service';
class MyApp extends Jimpex {
boot() {
this.register(myService);
this.register(
myService({
serviceName: 'myService2',
}),
);
}
}
Check the README of @homer0/jimple
for more information about providers.
To define a controller, you just need to use the controller
wrapper (like provider
), modify a router, and return it:
// src/health-controller.js
import { controller } from 'jimpex';
// (Optional) Define a class to organize your route handlers.
export class HealthController {
health() {
return (req, res) => {
res.write('Everything works!');
res.end();
};
}
}
// Define the controller
export const healthController = controller((app) => {
const ctrl = new HealthController();
// Get an instance of the router service
const router = app.get('router');
// Return the router with all the routes
return router.get('/', ctrl.health()).get('/health', (req, res) => {
res.write('Everything works!');
res.end();
});
});
- You could just export the controller, but I believe is a good practice to export both, in case another part of your app wants to extend the class and mount a new route with its inherited functionalities.
- In case you are wondering about the lazy loading of the services that you may inject, the function inside the
controller
wrapper won't be called until the app is started.
Then, on you app, you would mount
the controller:
import { Jimpex } from 'jimpex';
import { healthController } from './health-controller';
class MyApp extends Jimpex {
boot() {
this.mount('/health', healthController);
}
}
Jimpex already comes with a few built-in controllers ready to be used, and you can read about them on the controllers document.
The same as with the services, you can define controllers that accept custom options for the moment they are mounted:
// src/health-controller.js
import { controllerCreator } from 'jimpex';
// (Optional) Define a class to organize your route handlers.
export class HealthController {
health() {
return (req, res) => {
res.write('Everything works!');
res.end();
};
}
}
// Define the controller
export const healthController = controllerCreator((options = {}) => (app) => {
const ctrl = new HealthController();
// Get an instance of the router service
const router = app.get('router');
// Read the custom options
const { altRoute = '/health' } = options;
// Return the router with all the routes
return router.get('/', ctrl.health()).get(altRoute, (req, res) => {
res.write('Everything works!');
res.end();
});
});
And like with the provider creators, these can be used as a controller, or as a function:
import { Jimpex } from 'jimpex';
import { healthController } from './health-controller';
class MyApp extends Jimpex {
boot() {
this.mount('/health', healthController);
this.mount(
'/hp',
healthController({
altRoute: '/status',
}),
);
}
}
If for some reason, your controller needs to register a service that the rest of the container needs to have access to, and you plan to do it on the controller
/controllerCreator
callback, you could end up messing with the lazyness of the container: If a middleware or another controller tries to access the service and the controller that registers it is mounted after it, it will get an error as the service doesn't exist yet.
A way to solve this issue would be with a provider
/providerCreator
, mounting the controller:
// dont-do-this.js
import { provider, controller } from 'jimpex';
export const wrong = provider((app) => {
app.set('wrong', () => new WrongService());
app.mount(
'/',
controller((app) => {
const wrong = app.get('wrong');
return router.get('/', wrong.doSomething());
}),
);
});
The main problem with that approach is that you would have to register
it as a provider, so the route for the controller would need to be defined inside the callback.
So, for this case, Jimpex has a special wrapper, which is very similar, but that it allows you to mount
it:
// controller-provider.js
import { controllerProvider, controller } from 'jimpex';
export const good = controllerProvider((app) => {
app.set('good', () => new GoodService());
return controller((app) => {
const good = app.get('good');
return router.get('/', good.doSomething());
});
});
The controllerProvider
wrapper allows you to register something in the callback, and expects a wrapped controller
to be returned. That way, you can mount
it, and specify the root outside.
And like provider
, and controller
, there's also a controllerProviderCreator
.
To create a new middleware, you just need to use the middleware
wrapper and return it:
// my-middleware.js
import { middleware } from 'jimpex';
// Define your middleware function (or class if it gets more complex)
export const greetingsMiddleware = () => (req, res, next) => {
try {
res.write('Hello World!');
res.end();
} catch (err) {
next(err);
}
};
// Define the middleware
export const greetings = middleware(() => greetingsMiddleware());
Then, on the app, you would use
it:
import { Jimpex } from 'jimpex';
import { greetings } from './my-middleware';
class MyApp extends Jimpex {
boot() {
this.use(greetings);
}
}
Now, middlewares can also be mount
ed in specific routes:
import { Jimpex } from 'jimpex';
import { greetings } from './my-middleware';
class MyApp extends Jimpex {
boot() {
this.mount('/greetings', greetings);
}
}
And, if you don't need access to the container in the middleware definition, you could also mount
/use
it as a raw Express' middleware:
import { Jimpex } from 'jimpex';
const greetingsMiddleware = (req, res, next) => {
try {
res.write('Hello World!');
res.end();
} catch (err) {
next(err);
}
};
class MyApp extends Jimpex {
boot() {
this.use(greetingsMiddleware);
// or
this.mount('/greetings', greetingsMiddleware);
}
}
Jimpex already comes with a few built-in middlewares ready to be used, and you can read about them on the middlewares document.
Just like the controllerCreator
wrapper, you can also use the middlewareCreator
wrapper to define middlewares that accept custom options:
// my-middleware.js
import { middlwareCreator } from 'jimpex';
// Define your middleware function (or class if it gets more complex)
export const greetingsMiddleware = (message) => (req, res, next) => {
try {
res.write(message);
res.end();
} catch (err) {
next(err);
}
};
// Define the middleware
export const greetings = middleware((options) => {
const { message = 'Hello Charo!' } = options;
return greetingsMiddleware(message);
});
And now you can use it as a middleware, or as a function that returns a middleware:
import { Jimpex } from 'jimpex';
import { greetings } from './my-middleware';
class MyApp extends Jimpex {
boot() {
this.use(greetings);
// or
this.mount(
'/greetings',
greetings({
message: 'Hello Pili!',
}),
);
}
}
If, for some reason, your middleware needs to register a service before being mounted, just like the controllerProvider
, you have the middlewareProvider
wrapper:
// middleware-provider.js
import { middlewareProvider, middleware } from 'jimpex';
class MiddlewareService {
getMiddleware() {
return (req, res) => {
res.write('Everything works!');
res.end();
};
}
}
export const middlewareService = middlewareProvider((app) => {
app.set('middlewareService', () => new MiddlewareService());
return middleware((app) => {
const service = app.get('middlewareService');
return service.getMiddleware();
});
});
And finally, you also have middlewareProviderCreator
to create a middleware that can register a service, with custom options.
After v8, Jimpex was completely rewritten in TypeScript, so you can use it with no problems in your TypeScript projects. For more information, please refer to the TypeScript document.
You can find the example projects in the example
directory. To run them, you can use the npm run example
command. By default, it runs the basic
example, but you can also specify the name of the example you want to run:
npm run example [name-of-the-directory]
Script | Description |
---|---|
build |
Transpiles the TypeScript code. |
docs |
Generates the project documentation. |
lint |
Lints and formats the staged files. |
lint:all |
Lints the entire project code. |
types:check |
Validates the types definitions. |
test |
Runs the project unit tests. |
todo |
Lists all the pending to-do's. |
example |
Runs an example project. |
I use husky
to automatically install the repository hooks:
Hook | Description |
---|---|
commit-msg |
Ensures the use of conventional commits. |
pre-commit |
Lints and formats the staged files. |
pre-push |
Validates the types and run the tests. |
post-merge |
Updates the dependencies (npm i ). |
I use conventional commits with commitlint
in order to support semantic releases. The one that sets it up is actually husky
, since it installs a script that runs commitlint
on the commit message validation.
The configuration is on the commitlint
property of the package.json
.
I use semantic-release
and a GitHub action to automatically release on NPM everything that gets merged to main.
The configuration for semantic-release
is on ./releaserc
and the workflow for the release is on ./.github/workflow/release.yml
.
I use Jest to test the project.
The configuration file is on ./.jestrc.js
, the tests are on ./tests
and the script that runs it is on ./utils/scripts/test
.
For linting, I use ESlint with my own custom configuration.
There are two configuration files, ./.eslintrc
for the source and the tooling, and ./tests/.eslintrc
, and there's also a ./.eslintignore
to exclude some files.
For formatting, I use Prettier with my JSDoc plugin and my own custom configuration. The configuration file is ./.prettierrc
.
The script that runs them is ./utils/scripts/lint
; the script lint-all
only runs ESLint, and runs it for the entire project.
I use TypeDoc to generate an HTML documentation site for the project.
The configuration file is ./.typedoc.json
and the script that runs it is on ./utils/scripts/docs
.
A friend, who's also web developer, brought the idea of start using a dependency injection container on Node, and how Jimple was a great tool for it; from the moment I tried Jimple, I could never think of starting another project without it: It not only allows you to implement dependency injection on a simple and clean way, but it also kind of forces you to have a really good organization of your code.
A couple of months after that, the same friend told me that we should do something similar to Silex, which is based on Pimple, butwith Express. I ran with the idea and... this project is what I think a mix of Jimple and Express would look like. To be clear, this is not a port of Silex.