Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Middleware API #7190

Open
suneettipirneni opened this issue Jan 6, 2022 · 5 comments
Open

Proposal: Middleware API #7190

suneettipirneni opened this issue Jan 6, 2022 · 5 comments

Comments

@suneettipirneni
Copy link
Member

suneettipirneni commented Jan 6, 2022

Feature

Providing addons that are extensions to discord.js has been possible for a great while now, however the methods of doing so aren't very standardized. In addition, the ways of doing extensions for discord.js involve things like client subclassing. Often times, a developer just wants a way to preprocess the data recieved by discord, rather than trying to create a new client based on discord.js.

Instead, a more abstract way to interact with data received by the discord should be made. Introducing discord.js middleware.

What is Middleware?

As the name implies, middleware is code that runs inbetween two other code operations. This means custom tranformations or extra logic can be injected into the standard code execution sequence. All of these injections are done in a standardized way provided by the API.

The idea of middleware is far from uncommon, many popular libraries and frameworks have ways to inject middleware.

These packages include:

  • express.js
  • vue-router
  • next.js & nuxt.js
  • redux
  • socket.io
  • mongoose
  • and many more...

In this proposal, I'm proposing an API to inject middleware between the raw websocket event and the Client.on('event') public-facing events. This allows developers to easily preprocess data before the it
gets emitted to the client, which in turns allows for powerful addons.

How is this different from just attaching listeners to the client?

Event listeners aren't executed in the "middle" of anything. For example if I make a transformation in one client.on() event listener that doesn't affect the types in other client.on() listeners. Basically there's
never a preprocessing phase.

Ideal solution or implementation

To represent the potential power and simplicity of discord.js middleware, I'll set up some scenarios.

Scenario 1 - Translation/Internationalization Middleware

Since discord will be releasing a way to detect locale from an interaction, I want to set up a middleware that injects translation ability into the data object sent to the API.

I can create my middleware like so:

// TranslationInteraction.ts
import { translationModule } from './translation';

export class TranslationInteraction extends CommandInteraction {
   ...
   public override reply(...) {
     // translate content
	 ...
     data.content = translationModule.translate(data.content, this.locale);
     ...
   };

	// Wraps standard djs interaction.
	public static from(interaction: Interaction): TranslationInteraction { ... }
}
// Middleware.ts
import { TranslationInteraction } from "./TranslationInteraction";
import type { createMiddleware, NextFunction } from "discord.js";

export function TranslationMiddleware(
  interaction: Interaction,
  next: NextFunction<"interactionCreate">
) {
  // We're only interested in command interactions, if it's not
  // one, move on to the next middleware.
  if (!interaction.isCommand()) {
    // Pass along the interaction unmodified
    next(interaction);
    return;
  }

  // Invoke the next middleware in the pipeline with the
  // modified interaction object.
  next(TranslationInteraction.from(interaction));
}

// Wrap the middleware and export it
const Middleware = createMiddleware("interactionCreate", TranslationMiddleware);
export default Middleware;

Let's go over some of the things that happened here:

  • The next() function is a function that signifies that the middleware is complete and accepts data as a parameter to hand-off to the next middleware in the middleware pipeline. This allows data to be conditionally transformed based on conditions.

As for the user who wants to use this middleware, the setup is painless.

// index.ts

import { translationModule, TranslationMiddleware } from '@foo/translate';

// Init the translation files
translationModule.initFiles(path.join(__dirname, 'keys.json');

...

// Plug in the middleware.
client.use(TranslationMiddleware);

// handle interactions like normal.

client.on('interactionCreate', async (interaction) => {
	if (!interaction.isCommand() || interaction.commandName !== 'greet') return;

	await interaction.reply('GREET_LANG_KEY');
})

This code should result in and reply content being translated based on the key you give it. You barely even need to touch the middleware library, and you don't need to learn any new methods for your interactions.

Scenario 2 - Custom Command Handler

This one is a classic one, a basic command handler. A command handler can be middleware too. Let's see how we would accomplish that.

// middleware.ts
import { createMiddleware } from "discord.js";
import { dispatch } from "./dispatcher";

export function CommandHandlerMiddleware(interaction: Interaction, next) {
  if (!interaction.isCommand()) {
    next(interaction);
    return;
  }

  // Invoke command handler.
  dispatch(interaction.commandName);
  next(interaction);
}

const Middleware = createMiddleware(
  "interactionCreate",
  CommandHandlerMiddleware
);

export default Middleware;

Similarly to before, we can easily integrate this into our client:

// index.ts
import { CommandHandler, Middleware } from '@foo/commands';

CommandHandler.register(path.join(__dirname, 'commands'));

client.use('interactionCreate', Middleware);

...

// commands/MyCommand.ts
class MyCommand extends Command {
	constructor() { ... };
	run() { ... }
}

Just like that our command handler is fully integrated.

Ok but like what's the point of command handlers even being middleware?

Good question! The reason I gave a command handler as an example of middleware is not show that the command handler itself is better, but to instead show how it can integrate with other middleware.

Middleware works Together, not Apart

Ok great I have a translation middleware and a command middleware. But I want them to work together, I don't want to have to pick one over the other?

Good news, middleware moves in a Pipeline

The Pipeline

The pipeline works by passing information from one middleware to another in a sequential fashion. This is one of the purposes of the next function. It simply passes the information along to the next middleware in the pipeline. In essence, the pipeline enables the following:

  • Allows developers to make middleware without needing to manually add support for other middleware (it just works together(TM))
  • The pipeline order is customizable, you can chose which order each middleware executes in.
  • Middleware has the ability to control the flow of the pipeline ie by void returns.
  • Each consecutive middleware can add onto the previous middleware

Scenario 3 - Using Scenario 1 and 2 Together

Let's make both of these middlewares work together.

// index.ts
import { ..., TranslationMiddleware }  from  '@foo/translate';
import { ..., CommandMiddleware } from '@foo/commands';

...
// Let's use both middlewares!
client.use('interactionCreate', TranslationMiddleware, CommandMiddleware);
...

// commands/MyCommand.ts
export class MyCommand extends Command {
    ...
	run(interaction) {
		// Translations now work in my custom command handler!!
		await interaction.reply('TRANSLATION_KEY');
	}
}

All I needed to do was add TranslationMiddleware to the front of CommandMiddleware in client.use. Now it can take full advantage of the translations while not having to change anything about the command-handler middleware.

A Visual Representation

drawing

Pipeline Suspension

Pipelines can be suspended if next() is never invoked. This is useful for scenarios that cannot move on to another middleware. It's also useful for filtering events - for example, you can create explicit message filter:

  1. The explicit message filter middleware is invoked.
  2. It runs the message content through its filter and detects explicit message content.
  3. It deletes the message
  4. Bot DMs user who sent message

Note that next() is never invoked from this middleware. This means that this middleware has suspended the pipeline, this also means that the client.on('messageCreate') will never be called.

Feedback on implementation details is much appreciated.

Alternative solutions or implementations

No response

Other context

No response

@monbrey
Copy link
Member

monbrey commented Jan 6, 2022

A really interesting concept and I'd love to see it implemented in some form or another, but it does concern me that this could fall victim to some similar issues as Structures.extend did which resulted in it's removal - giving users the ability to modify objects and behaviours that discord.js expects to exist in a certain way.

@suneettipirneni
Copy link
Member Author

A really interesting concept and I'd love to see it implemented in some form or another, but it does concern me that this could fall victim to some similar issues as Structures.extend did which resulted in it's removal - giving users the ability to modify objects and behaviours that discord.js expects to exist in a certain way.

These are valid concerns. However, middleware is executed after djs has finished processing the raw ws event but before the the actual client event is invoked. This means that only users listening to events will receive changes not discord.js itself. So the only thing that changes is external behavior observed by the consumers of the library.

We can compare this to structures.extend which modifies both internal and external behaviors, which can lead to many unintended side effects.

@monbrey
Copy link
Member

monbrey commented Jan 6, 2022

I might be misunderstanding something from your example then - the translation middleware appears to be extending the Interaction class and overriding the reply method. While I agree this doesn't modify discord.js internal behaviours, how does it ensure they are still executed?

Would this act as a form of "outgoing" middleware which must eventually call the base interaction.reply with some form of modified MessageOptions?

@KokoNeotSide
Copy link

I suggested this few months back. I'd like a middleware so I can edit a message payload before its sent.

Some use cases:
translation
popup embeds (something like dankmemer but this way it would be easier, before each embed is sent, aditional embed can be inserted into the payload with tips or support and stuff like that)
some other validations
and many more

@nekomancer0
Copy link

We can do a thing simpler like Express with its .use method, that listens to event and can block, pass or update outgoing events ??

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants