Skip to content

Commit

Permalink
Add bullboard (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
robertherber authored Dec 26, 2023
1 parent 359ba9f commit 6993967
Show file tree
Hide file tree
Showing 10 changed files with 239 additions and 21 deletions.
Binary file modified bun.lockb
Binary file not shown.
162 changes: 162 additions & 0 deletions packages/bull/bullboard-hono-adapter.ts
Original file line number Diff line number Diff line change
@@ -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<HonoEnv extends Env> implements IServerAdapter {
protected app: Hono<HonoEnv>

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<HonoEnv>

protected nodeModulesRootPath: string = '.'

constructor(app: Hono<HonoEnv>) {
this.app = app
this.basePath = ''
this.uiConfig = {}
}

setBasePath(path: string): HonoAdapter<HonoEnv> {
this.basePath = path
this.app.basePath(path)
return this
}

setNodeModulesRootPath(path: string): HonoAdapter<HonoEnv> {
this.nodeModulesRootPath = path
return this
}

setStaticPath(staticsRoute: string, staticsPath: string): HonoAdapter<HonoEnv> {
staticsPath = staticsPath.replaceAll(/\/.*\/node_modules/gm, '/node_modules')
this.statics = { route: staticsRoute, path: staticsPath }
return this
}

setViewsPath(viewPath: string): HonoAdapter<HonoEnv> {
this.viewPath = viewPath
return this
}

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

setApiRoutes(routes: readonly AppControllerRoute[]): HonoAdapter<HonoEnv> {
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<HonoEnv>()
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<HonoEnv> {
this.entryRoute = routeDef
return this
}

setQueues(bullBoardQueues: BullBoardQueues): HonoAdapter<HonoEnv> {
this.bullBoardQueues = bullBoardQueues
return this
}

setUIConfig(config?: UIConfig): HonoAdapter<HonoEnv> {
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
}
}
4 changes: 2 additions & 2 deletions packages/bull/clients/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/bull/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: "3"
services:
redis:
image: redis:7
ports:
- "6379:6379"
7 changes: 2 additions & 5 deletions packages/bull/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -50,6 +46,7 @@
"zemble-plugin-auth": "workspace:*"
},
"devDependencies": {
"@types/ejs": "^3.1.5",
"@types/bun": "latest"
}
}
58 changes: 51 additions & 7 deletions packages/bull/plugin.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -29,6 +33,15 @@ export interface BullPluginConfig extends Zemble.GlobalConfig {
* Redis config to use for pubsub
*/
readonly redisOptions?: RedisOptions

readonly bullboard?: {
readonly ui?: Partial<UIConfig>
/**
* needs to be adjusted for monorepos with node_modules at another level than cwd
* */
readonly nodeModulesRootPath?: string
readonly basePath?: string
} | false
}

const defaults = {
Expand All @@ -44,16 +57,47 @@ export type { ZembleQueueConfig }
export { ZembleQueue }

// eslint-disable-next-line unicorn/consistent-function-scoping
export default new PluginWithMiddleware<BullPluginConfig>(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<BullPluginConfig>(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({
Expand Down
11 changes: 8 additions & 3 deletions packages/bull/utils/setupQueues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly Queue[]> => {
const queuePath = path.join(pluginPath, '/queues')

const hasQueues = fs.existsSync(queuePath)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/core/PluginInternal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions packages/graphql/clients/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/kv/clients/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down

0 comments on commit 6993967

Please sign in to comment.