DEV Community

Cover image for Writing a Modern MUSH Server with Typescript Part 2: The Input Parser
Lem Canady
Lem Canady

Posted on • Edited on

Writing a Modern MUSH Server with Typescript Part 2: The Input Parser

In Part 1 of the tutorial series, we went over some basic setup for the project. We also addressed some of the overall goals for the UrsaMU project.

The Parser

First, we're going to setup the parser that will handle input from a socket, then we'll define the socket server and accompanying support code!

First, we need to define a couple of interfaces to act as contracts from what we expect the shape of our data to look like.

import { Socket } from "socket.io";
import { Marked } from "@ts-stack/markdown";
import text from "../api/text";

export type MiddlewareNext = (
  err: Error | null,
  req: MuRequest
) => Promise<any>;

export type MiddlewareLayer = (
  data: MuRequest,
  next: MiddlewareNext
) => Promise<MuResponse>;

export interface MuRequest {
  socket: Socket;
  payload: {
    command: string;
    message?: string;
    [key: string]: any;
  };
}
export interface MuResponse {
  id: string;
  payload: {
    command: string;
    message?: string;
    [key: string]: any;
  };
}

Now we define the Parser class itself. Like MU, this is meant to be a singleton, needed only once through the life of the process.

export class Parser {
  private stack: MiddlewareLayer[];
  private static instance: Parser;

  private constructor() {
    this.stack = [];
  }

  static getInstance(){
    if(!this.instance) this.instance = new Parser();
    return this.instance;
  }

This is the method that we're going to call to handle input from the sockets.

  async process(req: MuRequest): Promise<MuResponse> {
    const command = req.payload.command;
    const socket = req.socket;
    const message = req.payload.message;
    const data = req.payload.data;

    switch (command) {
      case "message":
        return this._handle(req);
      case "connect":
        return {
          id: socket.id,
          payload: {
            command: "message",
            message: text.get("connect")
              ? Marked.parse(text.get("connect"))
              : "File Not Found!"
          }
        };
      default:
        return {
          id: socket.id,
          payload: {
            command: "message",
            message
          }
        };
    }
  }

Here is where I've started to define the middleware system that will handle piping an input string through a series of transforms before returning the results to the client. It will use a next() function, like one would expect to see in ExpressJS. :)

  /**
   * Add a new middleware to the stack.
   * @param layer The layer to add to the middleware stack.
   */
  use(layer: MiddlewareLayer) {
    this.stack.push(layer);
  }

  /**
   * Run a string through a series of middleware.
   * @param req The request object to be pushed through the pipeline.
   */
  private async _handle(req: MuRequest): Promise<MuResponse> {
    let idx = 0;

Here's where it gets a little fun! We're going to make a recursive function expression. Every piece of software that goes through the middleware system will have to make sure to individually call next(), or the server will hang!

    /**
     * Recursive function to walk through each piece of
     * middleware in the stack.
     * @param err Any possible errors hit by the middleware.
     * @param data The string the middleware pipeline is going
     * to be working with
     */
    const next = async (
      err: Error | null,
      req: MuRequest
    ): Promise<MuResponse> => {
      // Return early if there's an error, or if we've processed through
      // the entire stack.
      if (err != null) return Promise.reject(err);
      if (idx === this.stack.length) {
        return {
          id: req.socket.id,
          payload: req.payload
        };
      }

      // Grab a new layer from the stack
      const layer = this.stack[idx++];
      // Run the layer
      return await layer(req, next);
    };

    // Return the modified data.
    return await next(null, req).catch((err: Error) => next(err, req));
  }
}

export default Parser.getInstance();

Updating MU

Now we're going to update mu.ts to handle the server traffic from the sockets and routing them to our parser system. We're going to add the following to the startup() method.

async start(callback?: () => void) {
    // Handle new client connections.
    this.io?.on("connection", async (socket: Socket) => {
      const { id, payload }: MuResponse = await parser.process({
        socket: socket,
        payload: {
          command: "connect"
        }
      });

      // Send the results back to the client.
      this.io?.to(id).send(payload);

      // When a new message comes from the client, process
      // it and return the results.
      socket.on("message", async (message: string) => {
        const { id, payload }: MuResponse = await parser.process({
          socket,
          payload: { command: "message", message }
        });

        // Send the results back to the client after converting
        // any markdown.
        if (payload.message) payload.message = Marked.parse(payload.message);
        this.io?.to(id).send(payload);
      });
    });

Well, that's it for today! We've made our middleware system, and handled our socket.io connections and client activity! Perfect! In the next installment, we'll create a piece of middleware to handle commands, define a command or two!

Thanks for stopping in for a read! please feel free to leave a comment, ask a question or just discuss!

Top comments (1)

Collapse
 
danstockham profile image
Dan Stockham • Edited

It would help immensely if there was a filename accompanied by the code snippet. Also, some of the imports I can't seem to find from the previous step nor is there a reference to the code in this step.

For instances this line
import { text } from '../api/text'

What is that? I don't see a reference to it.