Consider implementing a Remix adapter
Kashuab opened this issue · comments
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