diff --git a/bun.lockb b/bun.lockb index d2771bb3..c694fa25 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/bull/bullboard-hono-adapter.ts b/packages/bull/bullboard-hono-adapter.ts new file mode 100644 index 00000000..4b4fa3ae --- /dev/null +++ b/packages/bull/bullboard-hono-adapter.ts @@ -0,0 +1,162 @@ +/* eslint-disable import/no-extraneous-dependencies, no-param-reassign, functional/prefer-readonly-type, functional/immutable-data */ +import ejs from 'ejs' +import { Hono } from 'hono' +import { serveStatic } from 'hono/bun' + +import type { + AppControllerRoute, AppViewRoute, BullBoardQueues, ControllerHandlerReturnType, IServerAdapter, UIConfig, +} from '@bull-board/api/dist/typings/app' +import type { Context, Env } from 'hono' + +export default class HonoAdapter implements IServerAdapter { + protected app: Hono + + protected basePath: string + + protected bullBoardQueues: BullBoardQueues | undefined + + protected errorHandler: ((error: Error) => ControllerHandlerReturnType) | undefined + + protected uiConfig?: UIConfig + + protected statics: { route?: string, path?: string } = {} + + protected entryRoute?: AppViewRoute + + protected viewPath?: string + + protected apiRoutes?: Hono + + protected nodeModulesRootPath: string = '.' + + constructor(app: Hono) { + this.app = app + this.basePath = '' + this.uiConfig = {} + } + + setBasePath(path: string): HonoAdapter { + this.basePath = path + this.app.basePath(path) + return this + } + + setNodeModulesRootPath(path: string): HonoAdapter { + this.nodeModulesRootPath = path + return this + } + + setStaticPath(staticsRoute: string, staticsPath: string): HonoAdapter { + staticsPath = staticsPath.replaceAll(/\/.*\/node_modules/gm, '/node_modules') + this.statics = { route: staticsRoute, path: staticsPath } + return this + } + + setViewsPath(viewPath: string): HonoAdapter { + this.viewPath = viewPath + return this + } + + setErrorHandler(handler: (error: Error) => ControllerHandlerReturnType): this { + this.errorHandler = handler + return this + } + + setApiRoutes(routes: readonly AppControllerRoute[]): HonoAdapter { + 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'`) + } + + const router = new Hono() + routes.forEach((route) => { + // @ts-expect-error hey + router[route.method.toString().toLowerCase()](route.route.toString(), async (c: Context) => { + try { + const response = await route.handler({ + queues: this.bullBoardQueues as BullBoardQueues, + params: c.req.param(), + query: c.req.query(), + }) + return c.json(response.body, response.status || 200) + } catch (e) { + if (!this.errorHandler || !(e instanceof Error)) { + throw e + } + + const response = this.errorHandler(e) + if (typeof response.body === 'string') { + return c.text(response.body, response.status) + } + return c.json(response.body, response.status) + } + }) + }) + + this.apiRoutes = router + return this + } + + setEntryRoute(routeDef: AppViewRoute): HonoAdapter { + this.entryRoute = routeDef + return this + } + + setQueues(bullBoardQueues: BullBoardQueues): HonoAdapter { + this.bullBoardQueues = bullBoardQueues + return this + } + + setUIConfig(config?: UIConfig): HonoAdapter { + this.uiConfig = config as UIConfig + return this + } + + getRouter() { + return this.app // Return the Hono application instance + } + + registerPlugin() { + if (!this.statics.path || !this.statics.route) { + throw new Error(`Please call 'setStaticPath' before using 'registerPlugin'`) + } else if (!this.entryRoute) { + throw new Error(`Please call 'setEntryRoute' before using 'registerPlugin'`) + } else if (!this.viewPath) { + throw new Error(`Please call 'setViewsPath' before using 'registerPlugin'`) + } else if (!this.apiRoutes) { + throw new Error(`Please call 'setApiRoutes' before using 'registerPlugin'`) + } else if (!this.bullBoardQueues) { + throw new Error(`Please call 'setQueues' before using 'registerPlugin'`) + } else if (!this.errorHandler) { + throw new Error(`Please call 'setErrorHandler' before using 'registerPlugin'`) + } + + this.app.basePath(this.basePath).get( + `${this.statics.route}/*`, + serveStatic({ + root: this.nodeModulesRootPath, // needs to be adjusted for monorepos with node_modules at another level + rewriteRequestPath: (path) => { + const newPath = path.replace([this.basePath, this.statics.route].join(''), this.statics.path as string) + return newPath + }, + }), + ) + + this.app.basePath(this.basePath).route('/', this.apiRoutes) + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { method, handler } = this.entryRoute + const routes = this.entryRoute.route as readonly string[] + routes.forEach((route) => { + this.app.basePath(this.basePath)[method](route, async (c: Context) => { + // eslint-disable-next-line @typescript-eslint/await-thenable + const { name: fileName, params } = await handler({ basePath: this.basePath, uiConfig: this.uiConfig || {} }) + const template = await ejs.renderFile(`${this.viewPath}/${fileName}`, params) + return c.html(template) + }) + }) + + return this + } +} diff --git a/packages/bull/clients/redis.ts b/packages/bull/clients/redis.ts index d68f860e..92ff17aa 100644 --- a/packages/bull/clients/redis.ts +++ b/packages/bull/clients/redis.ts @@ -10,7 +10,7 @@ export const createClient = (redisUrl: string, options?: RedisOptions): Redis => // throw new Error('Redis client is not available in test environment'); } - console.info(`Connecting to Redis at ${redisUrl}`) + console.info(`[@zemble/bull] Connecting to Redis at ${redisUrl}`) const redis = new Redis(redisUrl, { maxRetriesPerRequest: null, @@ -24,7 +24,7 @@ export const createClient = (redisUrl: string, options?: RedisOptions): Redis => }) redis.on('connect', () => { - console.info('Connected to Redis') + console.info('[@zemble/bull] Connected to Redis') }) return redis diff --git a/packages/bull/docker-compose.yml b/packages/bull/docker-compose.yml new file mode 100644 index 00000000..5684195c --- /dev/null +++ b/packages/bull/docker-compose.yml @@ -0,0 +1,6 @@ +version: "3" +services: + redis: + image: redis:7 + ports: + - "6379:6379" diff --git a/packages/bull/package.json b/packages/bull/package.json index 5dae121c..cd5af0a5 100644 --- a/packages/bull/package.json +++ b/packages/bull/package.json @@ -34,14 +34,10 @@ "license": "ISC", "dependencies": { "@bull-board/api": "^5.10.2", - "@bull-board/express": "^5.10.2", - "@bull-board/fastify": "^5.10.2", - "@bull-board/hapi": "^5.10.2", - "@bull-board/koa": "^5.10.2", - "@bull-board/nestjs": "^5.10.2", "@zemble/core": "workspace:*", "@zemble/graphql": "workspace:*", "bullmq": "^5.0.0", + "ejs": "^3.1.9", "graphql": "^16.8.1", "graphql-scalars": "^1.22.4", "hono": "^3.11.10", @@ -50,6 +46,7 @@ "zemble-plugin-auth": "workspace:*" }, "devDependencies": { + "@types/ejs": "^3.1.5", "@types/bun": "latest" } } diff --git a/packages/bull/plugin.ts b/packages/bull/plugin.ts index 15bab729..4c0875a0 100644 --- a/packages/bull/plugin.ts +++ b/packages/bull/plugin.ts @@ -1,10 +1,14 @@ +import { createBullBoard } from '@bull-board/api' +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter' import { PluginWithMiddleware } from '@zemble/core' import GraphQL from '@zemble/graphql' +import HonoAdapter from './bullboard-hono-adapter' import setupQueues from './utils/setupQueues' import ZembleQueue from './ZembleQueue' import type { ZembleQueueConfig } from './ZembleQueue' +import type { UIConfig } from '@bull-board/api/dist/typings/app' import type { RedisOptions, } from 'bullmq' @@ -29,6 +33,15 @@ export interface BullPluginConfig extends Zemble.GlobalConfig { * Redis config to use for pubsub */ readonly redisOptions?: RedisOptions + + readonly bullboard?: { + readonly ui?: Partial + /** + * needs to be adjusted for monorepos with node_modules at another level than cwd + * */ + readonly nodeModulesRootPath?: string + readonly basePath?: string + } | false } const defaults = { @@ -44,16 +57,47 @@ export type { ZembleQueueConfig } export { ZembleQueue } // eslint-disable-next-line unicorn/consistent-function-scoping -export default new PluginWithMiddleware(import.meta.dir, ({ plugins, context: { pubsub }, config }) => { - plugins.forEach(({ pluginPath, config }) => { - if (!config.middleware?.['zemble-plugin-bull']?.disable) { - setupQueues(pluginPath, pubsub, config) - } - }) +export default new PluginWithMiddleware(import.meta.dir, async ({ + plugins, context: { pubsub }, config, app, +}) => { const appPath = process.cwd() - setupQueues(appPath, pubsub, config) + + const allQueues = [ + ...(await Promise.all(plugins.map(async ({ pluginPath, config }) => { + if (!config.middleware?.['zemble-plugin-bull']?.disable) { + return setupQueues(pluginPath, pubsub, config) + } + return [] + }))).flat(), + ...await setupQueues(appPath, pubsub, config), + ] + + if (config.bullboard !== false && process.env.NODE_ENV !== 'test') { + const serverAdapter = new HonoAdapter(app.hono) + createBullBoard({ + queues: allQueues.map((q) => new BullMQAdapter(q)), + serverAdapter, + options: { + uiConfig: { + boardTitle: 'Zemble Queues', + ...config.bullboard?.ui, + }, + }, + }) + + serverAdapter.setBasePath(config.bullboard?.basePath ?? '/queues') + if (config.bullboard?.nodeModulesRootPath) { + serverAdapter.setNodeModulesRootPath(config.bullboard?.nodeModulesRootPath) + } + serverAdapter.registerPlugin() + } }, { defaultConfig: defaults, + devConfig: { + bullboard: { + nodeModulesRootPath: '../..', + }, + }, dependencies: [ { plugin: GraphQL.configure({ diff --git a/packages/bull/utils/setupQueues.ts b/packages/bull/utils/setupQueues.ts index f1a93c6c..41b0b63b 100644 --- a/packages/bull/utils/setupQueues.ts +++ b/packages/bull/utils/setupQueues.ts @@ -18,7 +18,11 @@ import type { // eslint-disable-next-line functional/prefer-readonly-type const queues: Queue[] = [] -const setupQueues = (pluginPath: string, pubSub: Zemble.PubSubType, config: BullPluginConfig | undefined) => { +const setupQueues = async ( + pluginPath: string, + pubSub: Zemble.PubSubType, + config: BullPluginConfig | undefined, +): Promise => { const queuePath = path.join(pluginPath, '/queues') const hasQueues = fs.existsSync(queuePath) @@ -53,7 +57,7 @@ const setupQueues = (pluginPath: string, pubSub: Zemble.PubSubType, config: Bull if (redisUrl || process.env.NODE_ENV === 'test') { const filenames = readDir(queuePath) - filenames.forEach(async (filename) => { + await Promise.all(filenames.map(async (filename) => { const fileNameWithoutExtension = filename.substring(0, filename.length - 3) const queueConfig = (await import(path.join(queuePath, filename))).default @@ -88,11 +92,12 @@ const setupQueues = (pluginPath: string, pubSub: Zemble.PubSubType, config: Bull } else { throw new Error(`Failed to load queue ${filename}, make sure it exports a ZembleQueue`) } - }) + })) } else { console.error('[bull-plugin] Failed to initialize. No redisUrl provided for bull plugin, you can specify it directly or with REDIS_URL') } } + return queues } export const getQueues = () => queues diff --git a/packages/core/PluginInternal.ts b/packages/core/PluginInternal.ts index b89c4203..1066b66c 100644 --- a/packages/core/PluginInternal.ts +++ b/packages/core/PluginInternal.ts @@ -43,6 +43,10 @@ export class Plugin< const resolvedDeps = filteredDeps .map(({ plugin, config }) => plugin.configure(config)) + if (this.#isPluginDevMode) { + this.configure(this.devConfig) + } + this.dependencies = resolvedDeps } diff --git a/packages/graphql/clients/redis.ts b/packages/graphql/clients/redis.ts index f52c6431..f69d24b2 100644 --- a/packages/graphql/clients/redis.ts +++ b/packages/graphql/clients/redis.ts @@ -11,7 +11,7 @@ export const createClient = (redisUrl: string, options?: RedisOptions): Redis => // throw new Error('Redis client is not available in test environment'); } - console.info(`Connecting to Redis at ${redisUrl}`) + console.info(`[@zemble/graphql] Connecting to Redis at ${redisUrl}`) const redis = new Redis(redisUrl, { maxRetriesPerRequest: null, @@ -25,7 +25,7 @@ export const createClient = (redisUrl: string, options?: RedisOptions): Redis => }) redis.on('connect', () => { - console.info('Connected to Redis') + console.info('[@zemble/graphql] Connected to Redis') }) return redis diff --git a/packages/kv/clients/redis.ts b/packages/kv/clients/redis.ts index d68f860e..2ce3b26b 100644 --- a/packages/kv/clients/redis.ts +++ b/packages/kv/clients/redis.ts @@ -10,7 +10,7 @@ export const createClient = (redisUrl: string, options?: RedisOptions): Redis => // throw new Error('Redis client is not available in test environment'); } - console.info(`Connecting to Redis at ${redisUrl}`) + console.info(`[@zemble/kv] Connecting to Redis at ${redisUrl}`) const redis = new Redis(redisUrl, { maxRetriesPerRequest: null, @@ -24,7 +24,7 @@ export const createClient = (redisUrl: string, options?: RedisOptions): Redis => }) redis.on('connect', () => { - console.info('Connected to Redis') + console.info('[@zemble/kv] Connected to Redis') }) return redis