Rafter is a lightweight, slightly opinionated Javascript framework for rapid development of web applications.
- is built using Typescript.
- is built on top of Expressjs.
- eliminates the tedious wiring of routes, middleware and services.
- allows decoupling of services by utilizing dependency injection via the autoloading service container Awilix.
- is flexible, reusable and testable.
yarn add rafter
yarn bootstrap & yarn build & yarn start:example:simple
This will build and run the simple example project. You can access it via http://localhost:3000.
Dependency autoloading is at the heart of Rafter, and the most opinionated portion of the framework. Rafter utilizes Awilix under the hood to automatically scan your application directory and register services . So there's no need to maintain configuration or add annotations, as long as the function or constructor arguments are the same name, it will wire everything up automatically.
Logger.ts
class Logger implements ILogger {
public log(...args: any[]): void {
console.log(args);
}
}
MyService.ts
class MyService {
constructor(private readonly logger: ILogger) {}
public run(): void {
this.logger.log('I have been autowired');
}
}
The Rafter autoloader will look recursively throughout your project for services, functions and config. This means you
do not need to statically import
all your dependencies, which maintains separation of concerns and improves
reusability.
The following configuration files are autoloaded by Rafter.
config.ts
: a general application or module config.middleware.ts
: registers services as middleware and loads them into the routes stack.routes.ts
: links controller services to route definitions.preStartHooks.ts
: loads defined services before Rafter has started the server.plugins.ts
: a config file outlining which plugins to load.
The config file (config.ts
) is a place to define all your application style config.
export default () => ({
db: {
connection: 'mongodb://localhost:27000/rafter' || process.env.NODE_DB_CONNECTION,
},
server: {
port: 3000,
},
example: {
message: `Hello Mars`,
},
});
This config can be referenced within the injected dependencies.
The middleware file (middleware.js
) exports an array of service name references which will be loaded/registered in the
order in which they were defined. eg.
export default (): IMiddlewares => new Set<IMiddlewareConfig>([`corsMiddleware`, `authenticationMiddleware`]);
The routes file (routes.ts
) exports an array of objects which define the http method, route, controller and action.
eg.
export default (): IRoutes =>
new Set<IRouteConfig>([
{
endpoint: `/`,
controller: `exampleController`,
action: `index`,
method: `get`,
},
]);
This would call exampleController.index(req, res)
when the route GET /
is hit. exampleController
will be the name
of the autoloaded service.
The routes file (pre-start-hooks.js
) exports an array of service references that will be executed before Rafter has
started, in the order in which they were defined. This is useful for instantiating DB connections, logging etc.
export default (): IPreStartHooks => new Set<IPreStartHookConfig>([`connectDbService`]);
An example of the connectDbService
pre start hook would be:
export default (dbDao: IDBDatabaseDao, logger: ILogger) => {
return async function connect(): Promise<IDbConnection> {
logger.info(`Connecting to the database`);
return dbDao.connect();
};
};
By adding async
to the function, Rafter will wait for it to be successfully returned before continuing to the next
pre start hook, or will finish starting up if there are no more hooks.
Along with the aforementioned configs, all that is required to run Rafter is the following in an test.ts
file:
import rafter from 'rafter';
const run = async () => {
// define the paths you want to autoload
const paths = [join(__dirname, '/**/!(*.spec).@(ts|js)')];
// instantiate rafter
const rafterServer = rafter({ paths });
// start rafter server
await rafterServer.start();
};
run();
Once start()
is called, Rafter will:
- Scan through all your directories looking for config files, plugins and services.
- Autoload all
plugins
. - Autoload all other services, injecting their dependencies.
- Run all the
pre-start-hooks
. - Apply all the
middleware
. - Register all the
routes
. - Start the server.
To see an example project, visit the skeleton rafter app
repository, or look at the included simple example
application
within packages.
Rafter is slightly opinionated; which means we have outlined specific ways of doing some things. Not as much as say, Sails or Ruby on Rails, but just enough to provide a simple and fast foundation for your project.
The foundations of the Rafter framework are:
- Dependency injection
- Autoloading services
- Configuration
With the advent of RequireJs
, dependency injection (DI) had largely been thrown by the wayside in favor of requiring /
importing all your dependencies. This meant that your dependencies were hard coded in each file, resulting in code that
was not easily unit tested, nor replicable without rewrites.
eg.
import mongoose from 'mongoose';
const connect = async (connectionUrl) => {
await mongoose.connect(connectionUrl);
};
const find = async (query) => {
await mongoose.find(query);
};
export { connect, find };
export default class DbDao {
constructor(private readonly db: IDatabaseDao, private readonly config: { connectionUrl: string }) {}
public async connect(): Promise<IDatabaseConnection> {
return this.db.connect(this.config.connectionUrl);
}
public async find<T>(query: any): Promise<T> {
return this.db.find(query);
}
}
As you can see with DI, we can substitute any DB service rather than being stuck with mongoose. This insulates services
which use a data store from caring what particular store it is. eg. If our DB becomes slow, we can simply substitute
a CacheDao
instead, and no other services would have to change.
Many other DI frameworks require you to use special decorators to specify which services are injected where. Rafter on the other hand, utilizes the KISS method of dependency injection;
- All services you want to be available in the DI container must have a
default
export. - The
file name
of the service is thecamelCase
reference in the DI container eg.- File name:
lib/users/UserManager.ts
- Reference in DI container:
userManager
- File name:
- The variable names in the
class constructor
orfunction arguments
match the reference names (see above) in the DI container.
We have the following service with the filename: lib/CommentManager.ts
export default class CommentManager {
constructor(private readonly dbDao: DbDao, private readonly logger: ILogger) {}
public async getComment(id: string): Promise<Comment> {
this.logger.info(`Getting comment for id: ${id}`);
return this.dbDao.find(`SELECT * FROM comments WHERE id=${id}`);
}
}
If we want to inject the CommentManager
into another service we must name the variable in the constructor
commentManager
. eg.
export default class CommentController {
constructor(private readonly commentManager: CommentManager) {}
public async index(request: IRequest, response: IResponse): Promise<void> {
const comment = await this.commentManager.getComment(1);
response.json(comment);
}
}
Or, if you prefer to use functional programming, make sure the argument name is a camelCase
version of
the file name
:
export default (commentManager: CommentManager) => {
return async (request: IRequest, response: IResponse): Promise<void> => {
const comment = await this.commentManager.getComment(1);
response.json(comment);
};
};