felixmosh / bull-board

🎯 Queue background jobs inspector

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Consider implementing a Remix adapter

Kashuab opened this issue · comments

commented

I'm building a Remix app and can't figure out how to integrate bull-board with it. It doesn't seem any of the existing adapters are compatible with the fetch Request/Response interface. I know I can set up my Remix server to use express, but it'd be nice if it were more plug-and-play 😄

I implemented an integration for this today. We can't do dynamic routing in remix (so far as I've seen) so this implements some lite routing.

Here is the code.

// remix.config.js
module.exports = {
...
  routes: (defineRoutes) => {
      return defineRoutes((route) => {
        route(
          // Routes all bull-board requests to this one remix route
          "admin/bull-board/*", 
          "bull-board/bull-board.route.tsx",
        );
      })
    }
... 
}
// app/bull-board/RemixAdapter.ts
import { readFileSync } from "fs";
import type {
  AppControllerRoute,
  AppViewRoute,
  BullBoardQueues,
  ControllerHandlerReturnType,
  IServerAdapter,
  UIConfig,
} from "@bull-board/api/dist/typings/app";
import ejs from "ejs";

const fileExtMimeTypeMap = {
  css: "text/css",
  js: "text/javascript",
  svg: "image/svg+xml",
  png: "image/png",
} as Record<string, string>;

export class RemixAdapter implements IServerAdapter {
  protected bullBoardQueues: BullBoardQueues | undefined;
  protected errorHandler:
    | ((error: Error) => ControllerHandlerReturnType)
    | undefined;
  protected basePath = "";
  protected viewPath = "";
  protected staticPath = "";
  protected uiConfig!: UIConfig;
  protected entryPointViewHandler!: (req: Request) => string;
  protected apiRoutes!: AppControllerRoute[];

  constructor(basePath: string) {
    this.basePath = basePath;
  }

  private matchUrlToRoute(
    httpMethod: string,
    realUrl: string,
    route: AppControllerRoute
  ): { [key: string]: string } | null {
    const routPaths = Array.isArray(route.route) ? route.route : [route.route];
    for (const routePath of routPaths) {
      const params = this.matchUrlToRoutePath(realUrl, routePath);
      if (params && httpMethod.toLowerCase() === route.method) {
        return params;
      }
    }
    return null;
  }

  private matchUrlToRoutePath(
    realUrl: string,
    template: string
  ): { [key: string]: string } | null {
    const realUrlParts = realUrl.split("/");
    const templateParts = template.split("/");
    if (realUrlParts.length !== templateParts.length) {
      return null; // The real URL does not match the template
    }
    const params: { [key: string]: string } = {};
    for (let i = 0; i < realUrlParts.length; i++) {
      if (templateParts[i].startsWith(":")) {
        const paramName = templateParts[i].slice(1); // Remove the ':' prefix
        params[paramName] = realUrlParts[i];
      } else if (realUrlParts[i] !== templateParts[i]) {
        return null; // The real URL does not match the template
      }
    }
    return params;
  }

  public async handleRequest(request: Request) {
    const url = new URL(request.url);
    const path = url.pathname;
    if (request.url.includes("/static")) {
      const name = request.url.split("/static/")[1];

      const fileExtension = name.split(".").pop() ?? name;
      let mimeType = "text/plain";
      if (fileExtension in fileExtMimeTypeMap) {
        mimeType = fileExtMimeTypeMap[fileExtension];
      }

      const file = readFileSync(`${this.staticPath}/${name}`, "utf-8");
      return new Response(file, {
        headers: { "Content-Type": mimeType },
      });
    } else if (request.url.includes("/api")) {
      let params = {};
      let matchedRoute = null;
      for (const route of this.apiRoutes) {
        const routeParams = this.matchUrlToRoute(
          request.method,
          path.split(this.basePath)[1],
          route
        );
        if (routeParams) {
          matchedRoute = route;
          params = routeParams;
          break;
        }
      }

      if (!matchedRoute) {
        return new Response("Not Found", { status: 404 });
      }

      const query: Record<string, any> = {};
      for (const [key, value] of url.searchParams.entries()) {
        query[key] = value;
      }

      const response = await matchedRoute.handler({
        queues: this.bullBoardQueues as BullBoardQueues,
        query,
        params,
      });
      return new Response(JSON.stringify(response.body), {
        status: response.status || 200,
        headers: { "Content-Type": "application/json" },
      });
    }

    const responseHTML = this.entryPointViewHandler(request);
    return new Response(responseHTML, {
      headers: { "Content-Type": "text/html" },
    });
  }

  setQueues(bullBoardQueues: BullBoardQueues): IServerAdapter {
    this.bullBoardQueues = bullBoardQueues;
    return this;
  }

  setViewsPath(viewPath: string): IServerAdapter {
    this.viewPath = viewPath;
    return this;
  }

  setStaticPath(staticsRoute: string, staticsPath: string): IServerAdapter {
    this.staticPath = staticsPath;
    return this;
  }

  public setEntryRoute(routeDef: AppViewRoute): IServerAdapter {
    this.entryPointViewHandler = (_req: Request) => {
      const { name, params } = routeDef.handler({
        basePath: this.basePath,
        uiConfig: this.uiConfig,
      });

      const template = readFileSync(`${this.viewPath}/${name}`, "utf-8");
      return ejs.render(template, params);
    };

    return this;
  }

  public setErrorHandler(
    handler: (error: Error) => ControllerHandlerReturnType
  ) {
    this.errorHandler = handler;
    return this;
  }

  public setApiRoutes(routes: AppControllerRoute[]): IServerAdapter {
    if (!this.errorHandler) {
      throw new Error(
        `Please call 'setErrorHandler' before using 'registerPlugin'`
      );
    } else if (!this.bullBoardQueues) {
      throw new Error(`Please call 'setQueues' before using 'registerPlugin'`);
    }

    this.apiRoutes = routes;
    return this;
  }

  setUIConfig(config: UIConfig): IServerAdapter {
    this.uiConfig = config;
    return this;
  }
}
// app/bull-board/bull-board.route.tsx

import { RemixAdapter } from "./RemixAdapter";
import { BullMQAdapter } from "@bull-board/api/bullMQAdapter";
import { createBullBoard } from "@bull-board/api";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";

async function handleBullBoardRequest(request: Request) {
  const serverAdapter = new RemixAdapter("/admin/bull-board");

  const { jobService } = await WebDIContainer();

  createBullBoard({
    queues: jobService.queues.map(q => new BullMQAdapter(q)),
    serverAdapter,
  });

  const response = await serverAdapter.handleRequest(request);
  return response;
}

export async function action({ request }: ActionFunctionArgs) {
  return handleBullBoardRequest(request);
}

export async function loader({ request }: LoaderFunctionArgs) {
  return handleBullBoardRequest(request);
}

+1 on this