The most interesting part of the Express/Koa architecture is the way middlewares can be composed. Compose in this case is very similar to function composition as in math (f โ g = f(g(x))
) but not exactly.
For our framework, the goal will look as follows:
// Create an instance of the framework
const app = new Diskoard();
// Call `use` to attach a middleware
app.use(async (ctx, next) => {
// A "middleware function" is async and takes two arguments:
// - `ctx`: the request context
// - `next`: a function to represent the next middleware in the chain
// In the ctx will initially just be the event data from `discord.js`, and the event type
// The middleware function can use that information to decide what to do next.
// For example, this middleware function will determine whether the event is a message
// that starts with our command prefix, and only if it is do we continue processing the
// message.
if (ctx.type === 'message') { // We're going to skip this `type` part for the first implementation! Don't worry about it for now.
const command = ctx.event.content;
if (cmd.startsWith(COMMAND_PREFIX)) {
// To share data with other middleware, we just modify the ctx object
ctx.command = command.slice(COMMAND_PREFIX.length);
await next(); // By calling `next()` we move on to the next handler, seen below
}
}
});
// Call `use` again to attach a second middleware. The order in which middleware is attached
// is the order in which they will be called when calling `next()`.
app.use(async (ctx, next) => {
// Since this is the second middleware, we know that the event was a message event,
// and we have updated ctx with a `.command` property containing the text of the
// command, so we can just use that to determine what to do next:
switch (ctx.command) {
case 'greet': return ctx.event.channel.send('Hello!');
default: ctx.event.channel.send(`Invalid command: ${ctx.command}.`);
}
});
The task
Set up the ability to compose middleware. The Diskoard
instance should hold, instead of a single this.handler
, an array of functions this.middleware = []
, which gets added to each time we call .use()
.
A new method async handle(event) {}
will be added to the class which creates the initial const ctx = { event };
, builds the next
function using the array of middleware, and then calls the first middleware in the list await this.middleware[0](ctx, next);
. Don't worry about the type
part mentioned in the example above, that will be a future feature. For now, we only receive message events anyway.
The next
function should:
- Be
async
- Take no arguments
- Call the next middleware in the list (if there is one) with the same
ctx
and a new next
function
- Return nothing
As a starting point, consider something like this:
function makeNextFunction(/* ?? */) {
return async () => {
const nextnext = makeNextFunction(/* ?? */);
return /* ? next middleware to call ? */(ctx, nextnext);
};
}
Dunno how much recursion (function that calls itself, like this hypothetical makeNextFunction
) you have seen, but it's a super useful and fun concept when you get the hang of it. Can be a bit tricky at first, if it's a new one to you let me know.
The last part of this task is to create a middleware function! There should be 2 calls to use
in the index.js
file: Edit: never mind this part, it will no longer be relevant since we have moved stuff to the database. I'll make future tasks for making real middleware to handle things as they come up.
If all goes according to plan, everything should be working at this point. If any trouble along the way don't hesitate to ask question.
Motivation
The advantage of this pattern is that we can handle each request a little bit at a time, reducing the amount of thinking we have to do at each step. We will eventually build individual middleware functions for things like:
- Determining the current user
- Loading common data from the database
- Determining which command was sent
- Logging all requests and responses in and out of the app
At that point it will hopefully look kind of like this:
app.use(logger());
app.use(determineUser());
app.use(loadUserState());
app.use(router()
.command('greet', (ctx) => ctx.reply('Hello!'))
.command('attack', attack));
Each middleware function will do its single task and then pass on to the next one, allowing us to build a very powerful request handler out of a bunch of tiny parts that are easy to build, test, debug, and maintain. It is also easy to share the more generic parts (like the logger middleware, which has nothing in particular to do with our game) as an open-source package on NPM for other Discord bots to use. Pretty neat.