diff --git a/bun.lockb b/bun.lockb index 482f759..5202f1e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..3ab2592 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[workspace] +packages = ["core", "example-app"] diff --git a/core/package.json b/core/package.json new file mode 100644 index 0000000..cce471a --- /dev/null +++ b/core/package.json @@ -0,0 +1,65 @@ +{ + "name": "@phoenix-framework/core", + "version": "1.0.0", + "module": "src/server.ts", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "bun run src/server.ts", + "lint": "eslint 'src/**/*.{ts,tsx}'", + "lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix", + "format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,scss,md}'", + "dev": "nodemon --exec bun run src/server.ts", + "test:e2e": "bun test e2e-tests" + }, + "peerDependencies": { + "typescript": "^5.4.5" + }, + "dependencies": { + "@typegoose/typegoose": "^12.5.0", + "apollo-server-express": "^3.13.0", + "bcrypt": "^5.1.1", + "bullmq": "^5.8.2", + "casbin": "^5.30.0", + "casbin-mongoose-adapter": "^5.3.1", + "dotenv": "^16.4.5", + "envalid": "^8.0.0", + "express": "^4.19.2", + "graphql": "^16.8.1", + "jsonwebtoken": "^9.0.2", + "kafkajs": "^2.2.4", + "mongoose": "^8.4.1", + "reflect-metadata": "^0.2.2", + "type-graphql": "^2.0.0-rc.1", + "typedi": "^0.10.0", + "winston": "^3.13.0", + "winston-daily-rotate-file": "^5.0.0" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/bun": "latest", + "@types/chai": "^4.3.16", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.14.2", + "@types/supertest": "^6.0.2", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "chai": "^5.1.1", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "mongodb-memory-server": "^9.3.0", + "nodemon": "^3.1.3", + "prettier": "^3.3.2", + "supertest": "^7.0.0", + "ts-mockito": "^2.6.1", + "ts-node": "^10.9.2" + }, + "overrides": { + "@types/express": "^4.17.21" + }, + "resolutions": { + "graphql": "^16.8.1" + } +} diff --git a/src/config/config.ts b/core/src/config/config.ts similarity index 100% rename from src/config/config.ts rename to core/src/config/config.ts diff --git a/src/config/database.ts b/core/src/config/database.ts similarity index 100% rename from src/config/database.ts rename to core/src/config/database.ts diff --git a/src/config/excluded-operations.ts b/core/src/config/excluded-operations.ts similarity index 100% rename from src/config/excluded-operations.ts rename to core/src/config/excluded-operations.ts diff --git a/src/config/logger.ts b/core/src/config/logger.ts similarity index 100% rename from src/config/logger.ts rename to core/src/config/logger.ts diff --git a/src/event/kafka-event-service.ts b/core/src/event/kafka-event-service.ts similarity index 91% rename from src/event/kafka-event-service.ts rename to core/src/event/kafka-event-service.ts index 2f62308..0fbdfe4 100644 --- a/src/event/kafka-event-service.ts +++ b/core/src/event/kafka-event-service.ts @@ -1,6 +1,5 @@ import { Kafka, type Producer, type Consumer } from 'kafkajs'; -import { Container, Service } from 'typedi'; -import env from '../config/config.ts'; +import { Service, Container } from 'typedi'; @Service() class KafkaEventService { @@ -13,7 +12,7 @@ class KafkaEventService { constructor() { this.kafka = new Kafka({ clientId: 'my-app', - brokers: [env.KAFKA_BROKER || 'localhost:29092'], // Use environment variable or default to localhost + brokers: [process.env.KAFKA_BROKER || 'localhost:29092'], // Use environment variable or default to localhost }); this.producer = this.kafka.producer(); @@ -82,4 +81,4 @@ class KafkaEventService { // Register the service with typedi Container.set(KafkaEventService, new KafkaEventService()); -export default KafkaEventService; +export { KafkaEventService }; diff --git a/core/src/index.ts b/core/src/index.ts new file mode 100644 index 0000000..15e7dfb --- /dev/null +++ b/core/src/index.ts @@ -0,0 +1,4 @@ +export * from './plugins/plugin-loader'; +export * from './plugins/global-context'; +export * from './plugins/plugin-interface'; +export * from './plugins/plugins-list'; diff --git a/src/middleware/auth.ts b/core/src/middleware/auth.ts similarity index 100% rename from src/middleware/auth.ts rename to core/src/middleware/auth.ts diff --git a/src/plugins/function-registry.ts b/core/src/plugins/function-registry.ts similarity index 100% rename from src/plugins/function-registry.ts rename to core/src/plugins/function-registry.ts diff --git a/src/plugins/global-context.ts b/core/src/plugins/global-context.ts similarity index 94% rename from src/plugins/global-context.ts rename to core/src/plugins/global-context.ts index 12feaa1..d4e126e 100644 --- a/src/plugins/global-context.ts +++ b/core/src/plugins/global-context.ts @@ -8,6 +8,7 @@ export type ResolverMap = { export interface GlobalContext { models: { [key: string]: { schema: Schema; model: any } }; resolvers: { [key: string]: Function[] }; + services: { [key: string]: any }; extendModel: (name: string, extension: (schema: Schema) => void) => void; extendResolvers: (name: string, extension: Function[]) => void; wrapResolver: (name: string, resolver: string, wrapper: Function) => void; diff --git a/src/plugins/plugin-interface.ts b/core/src/plugins/plugin-interface.ts similarity index 100% rename from src/plugins/plugin-interface.ts rename to core/src/plugins/plugin-interface.ts diff --git a/src/plugins/plugin-loader.ts b/core/src/plugins/plugin-loader.ts similarity index 82% rename from src/plugins/plugin-loader.ts rename to core/src/plugins/plugin-loader.ts index 26cf1a6..1a7cb38 100644 --- a/src/plugins/plugin-loader.ts +++ b/core/src/plugins/plugin-loader.ts @@ -2,16 +2,17 @@ import { Container } from 'typedi'; import { GraphQLSchema } from 'graphql'; import { buildSchema, type NonEmptyArray } from 'type-graphql'; import { statSync } from 'fs'; -import path, { join } from 'path'; -import logger from '../config/logger'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import logger from '../config/logger.js'; import mongoose, { Schema } from 'mongoose'; -import { type GlobalContext } from './global-context'; -import { type Plugin } from './plugin-interface'; -import pluginsList from './plugins-list'; -import { Queue, Worker, QueueEvents, type WorkerOptions } from 'bullmq'; -import env from '../config/config'; +import { type GlobalContext } from './global-context.js'; +import { type Plugin } from './plugin-interface.js'; +import { Queue, Worker, QueueEvents } from 'bullmq'; const loggerCtx = { context: 'plugin-loader' }; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); class PluginLoader { private plugins: Plugin[] = []; @@ -22,6 +23,7 @@ class PluginLoader { return { models: {}, resolvers: {}, + services: {}, extendModel: this.extendModel.bind(this), extendResolvers: this.extendResolvers.bind(this), wrapResolver: this.wrapResolver.bind(this), @@ -60,25 +62,27 @@ class PluginLoader { resolverArray[originalResolverIndex].prototype[resolverName] = wrapper(originalResolver); } - loadPlugins(): void { - pluginsList.forEach(this.loadPlugin.bind(this)); + async loadPlugins(pluginDirs: string[]): Promise { + for (const pluginDir of pluginDirs) { + await this.loadPlugin(pluginDir); + } } - private loadPlugin(pluginName: string): void { - const pluginPath = join(__dirname, pluginName); - if (!statSync(pluginPath).isDirectory()) return; + private async loadPlugin(pluginDir: string): Promise { + const pluginPath = join(pluginDir, 'index.js'); + if (!statSync(pluginDir).isDirectory()) return; try { - const plugin: Plugin = require(`./${pluginName}`).default; + const { default: plugin } = await import(pluginPath); if (!plugin) { - throw new Error(`Plugin in directory ${pluginName} does not have a default export`); + throw new Error(`Plugin in directory ${pluginDir} does not have a default export`); } logger.info(`Loaded plugin: ${plugin.name} of type ${plugin.type}`, loggerCtx); this.plugins.push(plugin); plugin.register?.(Container, this.context); logger.debug(`Registered plugin: ${plugin.name}`, loggerCtx); } catch (error) { - console.error(`Failed to load plugin from directory ${pluginName}:`, error); + console.error(`Failed to load plugin from directory ${pluginDir}:`, error); } } @@ -105,7 +109,7 @@ class PluginLoader { return await buildSchema({ resolvers: allResolvers as unknown as NonEmptyArray, container: Container, - emitSchemaFile: path.resolve(__dirname, '../../schema.graphql'), + emitSchemaFile: true, }); } catch (error) { logger.error(`Error building schema: ${error}`, loggerCtx); @@ -132,7 +136,7 @@ class PluginLoader { queueEvents.on('failed', (job, err) => { const errorMessage = this.extractErrorMessage(err); - logger.error(`Job ${job.jobId} in queue ${queueName} failed with error: ${errorMessage}`, loggerCtx);; + logger.error(`Job ${job.jobId} in queue ${queueName} failed with error: ${errorMessage}`, loggerCtx); }); this.queues[queueName] = queue; diff --git a/src/plugins/plugin.ts b/core/src/plugins/plugin.ts similarity index 100% rename from src/plugins/plugin.ts rename to core/src/plugins/plugin.ts diff --git a/src/plugins/plugins-list.ts b/core/src/plugins/plugins-list.ts similarity index 100% rename from src/plugins/plugins-list.ts rename to core/src/plugins/plugins-list.ts diff --git a/src/rbac.ts b/core/src/rbac.ts similarity index 100% rename from src/rbac.ts rename to core/src/rbac.ts diff --git a/src/sanitize-log.ts b/core/src/sanitize-log.ts similarity index 100% rename from src/sanitize-log.ts rename to core/src/sanitize-log.ts diff --git a/core/src/server.ts b/core/src/server.ts new file mode 100644 index 0000000..92e494d --- /dev/null +++ b/core/src/server.ts @@ -0,0 +1,97 @@ +import 'reflect-metadata'; +import express, { type Application, type Request, type Response, type NextFunction } from 'express'; +import { ApolloServer } from 'apollo-server-express'; +import env from './config/config.js'; +import logger from './config/logger.js'; +import { authenticate } from './middleware/auth.js'; +import { isIntrospectionQuery } from './utils/introspection-check.js'; +import { shouldBypassAuth } from './utils/should-bypass-auth.js'; +import sanitizeLog from './sanitize-log.js'; +import { initializeSharedResources } from './shared.js'; +import { startWorker } from './worker.js'; +import { getEnforcer } from './rbac.js'; +import PluginLoader from './plugins/plugin-loader.js'; + +const loggerCtx = { context: 'server' }; + +async function startServer(pluginLoader: PluginLoader) { + try { + const schema = await pluginLoader.createSchema(); + + const server = new ApolloServer({ + schema, + introspection: true, + context: async ({ req }) => ({ + user: req.user, + enforcer: await getEnforcer(), + pluginsContext: pluginLoader.context, + }), + }); + + await server.start(); + + const app: Application = express(); + + app.use(express.json()); + + app.use('/graphql', (req: Request, res: Response, next: NextFunction) => { + const reqInfo = { + url: req.url, + method: req.method, + ip: req.ip, + headers: req.headers, + operation: {}, + }; + + if (req.body && req.body.query) { + if (isIntrospectionQuery(req.body.query)) { + logger.verbose('Bypassing authentication for introspection query', loggerCtx); + return next(); + } + + if (shouldBypassAuth(req.body.query)) { + logger.verbose('Bypassing authentication due to excluded operation', loggerCtx); + return next(); + } + + try { + authenticate(req, res, next); + } catch (error) { + logger.error('Error parsing GraphQL query:', { error, query: req.body.query }); + authenticate(req, res, next); + } + } else { + authenticate(req, res, next); + } + const sanitizedReqInfo = sanitizeLog(reqInfo); + const logLine = JSON.stringify(sanitizedReqInfo, null, 0); + logger.verbose(`reqInfo: ${logLine}`, loggerCtx); + }); + + server.applyMiddleware({ app }); + + const port = env.PORT || 3000; + await new Promise((resolve) => { + app.listen(port, () => { + logger.info(`Server is running at http://localhost:${port}${server.graphqlPath}`, { context: 'server' }); + resolve(); + }); + }); + } catch (error) { + logger.error('Failed to start server:', error, loggerCtx); + } +} + +export async function startApp(pluginDirs: string[]) { + if (!pluginDirs) { + throw new Error('pluginDirs must be provided'); + } + + const pluginLoader = await initializeSharedResources(pluginDirs); + + // Start the server + await startServer(pluginLoader); + // await startWorker(pluginDirs, env.MODE === 'dev'); + + logger.info(`Server started, version 1.0.0`); +} diff --git a/core/src/shared.ts b/core/src/shared.ts new file mode 100644 index 0000000..f80ab0b --- /dev/null +++ b/core/src/shared.ts @@ -0,0 +1,38 @@ +import { readdirSync, statSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import logger from './config/logger.js'; +import PluginLoader from './plugins/plugin-loader.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const loggerCtx = { context: 'shared' }; + +async function initializeSharedResources(pluginDirs: string[]): Promise { + const pluginLoader = new PluginLoader(); + + logger.info('Initializing shared resources', loggerCtx); + + await pluginLoader.loadPlugins(pluginDirs); + pluginLoader.initializePlugins(); + + logger.info('Shared resources initialized', loggerCtx); + + return pluginLoader; +} + +async function loadPlugins(pluginsPath: string): Promise { + const pluginDirs: string[] = []; + const items = readdirSync(pluginsPath); + + for (const item of items) { + const itemPath = join(pluginsPath, item); + if (statSync(itemPath).isDirectory()) { + pluginDirs.push(itemPath); + } + } + + return pluginDirs; +} + +export { initializeSharedResources }; diff --git a/src/types/express.d.ts b/core/src/types/express.d.ts similarity index 100% rename from src/types/express.d.ts rename to core/src/types/express.d.ts diff --git a/src/types/jwt-payload.d.ts b/core/src/types/jwt-payload.d.ts similarity index 100% rename from src/types/jwt-payload.d.ts rename to core/src/types/jwt-payload.d.ts diff --git a/src/utils/introspection-check.ts b/core/src/utils/introspection-check.ts similarity index 100% rename from src/utils/introspection-check.ts rename to core/src/utils/introspection-check.ts diff --git a/src/utils/should-bypass-auth.ts b/core/src/utils/should-bypass-auth.ts similarity index 100% rename from src/utils/should-bypass-auth.ts rename to core/src/utils/should-bypass-auth.ts diff --git a/core/src/worker.ts b/core/src/worker.ts new file mode 100644 index 0000000..5174cf4 --- /dev/null +++ b/core/src/worker.ts @@ -0,0 +1,23 @@ +import logger from './config/logger.js'; +import { initializeSharedResources } from './shared.js'; +import PluginLoader from './plugins/plugin-loader.js'; + +const loggerCtx = { context: 'worker' }; + +export async function startWorker(pluginDirs: string[], isDevMode = false) { + let pluginLoader: PluginLoader; + try { + pluginLoader = await initializeSharedResources(pluginDirs); + + if (isDevMode) { + // Additional development mode-specific logic + logger.info('Worker running in development mode', loggerCtx); + } + + pluginLoader.initializeQueues(); + + logger.info('Worker started and ready to process jobs', loggerCtx); + } catch (error) { + logger.error('Failed to start worker:', error, loggerCtx); + } +} diff --git a/core/tsconfig.json b/core/tsconfig.json new file mode 100644 index 0000000..6d18b71 --- /dev/null +++ b/core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", // This should be relative to the core directory + "module": "ESNext", + "target": "ESNext" + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/example-app/.env.example b/example-app/.env.example new file mode 100644 index 0000000..ffd2ae6 --- /dev/null +++ b/example-app/.env.example @@ -0,0 +1,10 @@ +# MongoDB connection string +MONGO_URI=mongodb://root:example@localhost:27017/phoenix?authSource=admin +# Server port +PORT=4000 +# Your unique JWT secret +JWT_SECRET=your_jwt_secret +# Log level +LOG_LEVEL=debug + +KAFKAJS_NO_PARTITIONER_WARNING=1 diff --git a/example-app/package.json b/example-app/package.json new file mode 100644 index 0000000..25dc7d1 --- /dev/null +++ b/example-app/package.json @@ -0,0 +1,18 @@ +{ + "name": "myapp", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "bun run src/index.js" + }, + "dependencies": { + "@phoenix-framework/core": "file:../core", + "graphql": "^16.8.1", + "type-graphql": "^2.0.0-rc.1", + "typedi": "^0.10.0", + "mongoose": "^8.4.1", + "bullmq": "^5.8.2", + "express": "^4.19.2" + } +} diff --git a/example-app/src/index.ts b/example-app/src/index.ts new file mode 100644 index 0000000..30c0ee6 --- /dev/null +++ b/example-app/src/index.ts @@ -0,0 +1,40 @@ +import dotenv from 'dotenv'; +import { readdirSync, statSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import logger from '@phoenix-framework/core/src/config/logger.js'; +import { startApp } from '@phoenix-framework/core/src/server.js'; +import packageJson from '../package.json' assert { type: 'json' }; + +// Resolve the path to the .env file +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +dotenv.config({ path: join(__dirname, '../.env') }); + +async function loadPlugins(): Promise { + const pluginDirs: string[] = []; + const pluginsPath = join(__dirname, 'plugins'); + const items = readdirSync(pluginsPath); + + for (const item of items) { + const itemPath = join(pluginsPath, item); + if (statSync(itemPath).isDirectory()) { + pluginDirs.push(itemPath); + } + } + + return pluginDirs; +} + +async function runApp() { + try { + const pluginDirs = await loadPlugins(); + await startApp(pluginDirs); + logger.info(`Application initialized, version ${packageJson.version}`); + } catch (error) { + logger.error(error); + process.exit(1); + } +} + +runApp(); diff --git a/src/plugins/auth-plugin/bootstrap.ts b/example-app/src/plugins/auth-plugin/bootstrap.ts similarity index 100% rename from src/plugins/auth-plugin/bootstrap.ts rename to example-app/src/plugins/auth-plugin/bootstrap.ts diff --git a/src/plugins/auth-plugin/index.ts b/example-app/src/plugins/auth-plugin/index.ts similarity index 52% rename from src/plugins/auth-plugin/index.ts rename to example-app/src/plugins/auth-plugin/index.ts index b6c6180..13ff65c 100644 --- a/src/plugins/auth-plugin/index.ts +++ b/example-app/src/plugins/auth-plugin/index.ts @@ -1,8 +1,8 @@ -import { AuthResolver } from './resolvers/auth-resolver'; -import type { Plugin } from '../plugin-interface'; -import FunctionRegistry from '../function-registry'; -import { type GlobalContext } from '../global-context'; -import logger from '../../config/logger.ts'; +import { AuthResolver } from './resolvers/auth-resolver.js'; +import { type Plugin } from '@phoenix-framework/core/src/plugins/plugin-interface.js'; +import FunctionRegistry from '@phoenix-framework/core/src/plugins/function-registry.js'; +import { type GlobalContext } from '@phoenix-framework/core/src/plugins/global-context.js'; +import logger from '@phoenix-framework/core/src/config/logger.js'; const loggerCtx = { context: 'auth-plugin/register' }; @@ -10,7 +10,7 @@ const authPlugin: Plugin = { name: 'auth-plugin', type: 'authorization', resolvers: [AuthResolver], - register: (container: any, context: GlobalContext) => { + register: (container, context: GlobalContext) => { // Register resolvers context.resolvers['Auth'] = authPlugin.resolvers ?? []; diff --git a/src/plugins/auth-plugin/models/user.ts b/example-app/src/plugins/auth-plugin/models/user.ts similarity index 100% rename from src/plugins/auth-plugin/models/user.ts rename to example-app/src/plugins/auth-plugin/models/user.ts diff --git a/src/plugins/auth-plugin/resolvers/auth-resolver.ts b/example-app/src/plugins/auth-plugin/resolvers/auth-resolver.ts similarity index 94% rename from src/plugins/auth-plugin/resolvers/auth-resolver.ts rename to example-app/src/plugins/auth-plugin/resolvers/auth-resolver.ts index 8028645..03318e5 100644 --- a/src/plugins/auth-plugin/resolvers/auth-resolver.ts +++ b/example-app/src/plugins/auth-plugin/resolvers/auth-resolver.ts @@ -5,8 +5,8 @@ import { User } from '../models/user'; import { UserService } from '../services/user-service.ts'; import { Service } from 'typedi'; import jwt from 'jsonwebtoken'; -import { getEnforcer } from '../../../rbac'; -import env from '../../../config/config.ts'; +import { getEnforcer } from '@phoenix-framework/core/src/rbac.js' +import env from '@phoenix-framework/core/src/config/config.js'; @Service() // Register AuthResolver with Typedi @Resolver() diff --git a/src/plugins/auth-plugin/services/user-service.test.ts b/example-app/src/plugins/auth-plugin/services/user-service.test.ts similarity index 100% rename from src/plugins/auth-plugin/services/user-service.test.ts rename to example-app/src/plugins/auth-plugin/services/user-service.test.ts diff --git a/src/plugins/auth-plugin/services/user-service.ts b/example-app/src/plugins/auth-plugin/services/user-service.ts similarity index 90% rename from src/plugins/auth-plugin/services/user-service.ts rename to example-app/src/plugins/auth-plugin/services/user-service.ts index f1f8dcc..dfe5307 100644 --- a/src/plugins/auth-plugin/services/user-service.ts +++ b/example-app/src/plugins/auth-plugin/services/user-service.ts @@ -1,7 +1,7 @@ import { Service } from 'typedi'; import { User, UserModel } from '../models/user'; import bcrypt from 'bcrypt'; -import { getEnforcer } from '../../../rbac.ts'; +import { getEnforcer } from '@phoenix-framework/core/src/rbac.js'; @Service() export class UserService { diff --git a/src/plugins/cart-plugin/index.ts b/example-app/src/plugins/cart-plugin/index.ts similarity index 63% rename from src/plugins/cart-plugin/index.ts rename to example-app/src/plugins/cart-plugin/index.ts index cf271e8..8985bcd 100644 --- a/src/plugins/cart-plugin/index.ts +++ b/example-app/src/plugins/cart-plugin/index.ts @@ -1,8 +1,9 @@ import { Container } from 'typedi'; import { getModelForClass } from '@typegoose/typegoose'; -import { type GlobalContext } from '../global-context'; +import { type GlobalContext } from '@phoenix-framework/core/src/plugins/global-context'; import { Cart } from './models/cart'; import { CartResolver } from './resolvers/cart-resolver'; +import { CartService } from './services'; export default { name: 'cart-plugin', @@ -12,7 +13,14 @@ export default { const CartModel = getModelForClass(Cart); context.models['Cart'] = { schema: CartModel.schema, model: CartModel }; container.set('CartModel', CartModel); - container.set(CartResolver, new CartResolver()); // Register CartResolver explicitly + + // Register CartService explicitly + container.set(CartService, new CartService()); + + // Correctly instantiate CartResolver with CartService + const cartService = container.get(CartService); + container.set(CartResolver, new CartResolver(cartService)); + context.extendResolvers('Cart', [CartResolver]); const resolverMethods = Object.getOwnPropertyNames(CartResolver.prototype).filter( (method) => method !== 'constructor', diff --git a/src/plugins/cart-plugin/models/cart.ts b/example-app/src/plugins/cart-plugin/models/cart.ts similarity index 100% rename from src/plugins/cart-plugin/models/cart.ts rename to example-app/src/plugins/cart-plugin/models/cart.ts diff --git a/src/plugins/cart-plugin/resolvers/cart-resolver.ts b/example-app/src/plugins/cart-plugin/resolvers/cart-resolver.ts similarity index 88% rename from src/plugins/cart-plugin/resolvers/cart-resolver.ts rename to example-app/src/plugins/cart-plugin/resolvers/cart-resolver.ts index 00c30f8..1b832ff 100644 --- a/src/plugins/cart-plugin/resolvers/cart-resolver.ts +++ b/example-app/src/plugins/cart-plugin/resolvers/cart-resolver.ts @@ -8,11 +8,7 @@ import { Item } from '../models/cart'; @Service() @Resolver(() => Cart) export class CartResolver { - private cartService: CartService; - - constructor() { - this.cartService = new CartService(); - } + constructor(private readonly cartService: CartService) {} @Query(() => [Cart]) async getCarts(): Promise { diff --git a/example-app/src/plugins/cart-plugin/resolvers/inputs/item-input.ts b/example-app/src/plugins/cart-plugin/resolvers/inputs/item-input.ts new file mode 100644 index 0000000..52deee7 --- /dev/null +++ b/example-app/src/plugins/cart-plugin/resolvers/inputs/item-input.ts @@ -0,0 +1,19 @@ +import { InputType, Field, Int, Float } from 'type-graphql'; + +@InputType() +export class ItemInput { + @Field() + name!: string; + + @Field() + description!: string; + + @Field() + productId!: string; + + @Field(() => Int) + quantity!: number; + + @Field(() => Float) + price!: number; +} diff --git a/src/plugins/cart-plugin/services/index.ts b/example-app/src/plugins/cart-plugin/services/index.ts similarity index 100% rename from src/plugins/cart-plugin/services/index.ts rename to example-app/src/plugins/cart-plugin/services/index.ts diff --git a/src/plugins/discount-plugin/index.ts b/example-app/src/plugins/discount-plugin/index.ts similarity index 100% rename from src/plugins/discount-plugin/index.ts rename to example-app/src/plugins/discount-plugin/index.ts diff --git a/src/plugins/sample-plugin/index.ts b/example-app/src/plugins/sample-plugin/index.ts similarity index 70% rename from src/plugins/sample-plugin/index.ts rename to example-app/src/plugins/sample-plugin/index.ts index e851282..2b26ebd 100644 --- a/src/plugins/sample-plugin/index.ts +++ b/example-app/src/plugins/sample-plugin/index.ts @@ -1,13 +1,12 @@ import { Container } from 'typedi'; import { getModelForClass } from '@typegoose/typegoose'; -import { type GlobalContext } from '../global-context'; +import { type GlobalContext } from '@phoenix-framework/core/src/plugins/global-context.js'; +import logger from '@phoenix-framework/core/src/config/logger.js'; import { Sample } from './models/sample'; import { SampleResolver } from './resolvers/sample-resolver'; import { SampleService } from './services/sample-service'; -import KafkaEventService from '../../event/kafka-event-service'; +import { KafkaEventService } from '@phoenix-framework/core/src/event/kafka-event-service.js'; import { Queue, Job } from 'bullmq'; -import logger from '../../config/logger.ts'; -import env from '../../config/config.ts'; const loggerCtx = { context: 'sample-plugin/index' }; @@ -28,8 +27,8 @@ export default { // Define and register the queue for this plugin const sampleQueue = new Queue('sampleQueue', { connection: { - host: env.REDIS_HOST || 'localhost', - port: Number(env.REDIS_PORT) || 6379, + host: process.env.REDIS_HOST || 'localhost', + port: Number(process.env.REDIS_PORT) || 6379, }, }); @@ -37,18 +36,18 @@ export default { container.set('sampleQueue', sampleQueue); // Register SampleService and KafkaEventService with typedi - container.set(SampleService, new SampleService(Container.get(KafkaEventService), sampleQueue)); + const eventService = container.get(KafkaEventService); // Ensure KafkaEventService is registered + container.set(SampleService, new SampleService(eventService, sampleQueue)); // Ensure SampleResolver is added to context resolvers context.resolvers['Sample'] = [SampleResolver]; // Register the topic with KafkaEventService - const eventService = Container.get(KafkaEventService); eventService.registerTopic('sampleCreated'); // Set up event handlers using the centralized event service eventService.subscribeToEvent('sampleCreated', (sample) => { - logger.debug('Received sampleCreated event:', sample) + logger.debug('Received sampleCreated event:', sample); logger.info(`Sample created: ${sample}`, loggerCtx); // Additional handling logic here }); @@ -57,8 +56,8 @@ export default { processor: sampleJobProcessor, options: { connection: { - host: env.REDIS_HOST || 'localhost', - port: Number(env.REDIS_PORT) || 6379, + host: process.env.REDIS_HOST || 'localhost', + port: Number(process.env.REDIS_PORT) || 6379, }, }, }; diff --git a/src/plugins/sample-plugin/models/sample.ts b/example-app/src/plugins/sample-plugin/models/sample.ts similarity index 100% rename from src/plugins/sample-plugin/models/sample.ts rename to example-app/src/plugins/sample-plugin/models/sample.ts diff --git a/src/plugins/sample-plugin/resolvers/sample-resolver.ts b/example-app/src/plugins/sample-plugin/resolvers/sample-resolver.ts similarity index 90% rename from src/plugins/sample-plugin/resolvers/sample-resolver.ts rename to example-app/src/plugins/sample-plugin/resolvers/sample-resolver.ts index 91a05e0..0a144f2 100644 --- a/src/plugins/sample-plugin/resolvers/sample-resolver.ts +++ b/example-app/src/plugins/sample-plugin/resolvers/sample-resolver.ts @@ -2,7 +2,7 @@ import { Resolver, Query, Mutation, Arg } from 'type-graphql'; import { Inject, Service } from 'typedi'; import { Sample } from '../models/sample'; import { SampleService } from '../services/sample-service'; -import FunctionRegistry from '../../function-registry'; +import FunctionRegistry from '@phoenix-framework/core/src/plugins/function-registry.js'; @Service() @Resolver() diff --git a/src/plugins/sample-plugin/services/sample-service.ts b/example-app/src/plugins/sample-plugin/services/sample-service.ts similarity index 85% rename from src/plugins/sample-plugin/services/sample-service.ts rename to example-app/src/plugins/sample-plugin/services/sample-service.ts index c29f4bd..06decf1 100644 --- a/src/plugins/sample-plugin/services/sample-service.ts +++ b/example-app/src/plugins/sample-plugin/services/sample-service.ts @@ -1,8 +1,8 @@ import { Service } from 'typedi'; import { Sample, SampleModel } from '../models/sample'; -import KafkaEventService from '../../../event/kafka-event-service'; +import { KafkaEventService } from '@phoenix-framework/core/src/event/kafka-event-service.js'; import { Queue } from 'bullmq'; -import logger from '../../../config/logger.ts'; +import logger from '@phoenix-framework/core/src/config/logger.js'; @Service() export class SampleService { diff --git a/src/plugins/sample-plugin/services/sample-worker.ts b/example-app/src/plugins/sample-plugin/services/sample-worker.ts similarity index 100% rename from src/plugins/sample-plugin/services/sample-worker.ts rename to example-app/src/plugins/sample-plugin/services/sample-worker.ts diff --git a/example-app/tsconfig.json b/example-app/tsconfig.json new file mode 100644 index 0000000..dd3fe72 --- /dev/null +++ b/example-app/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "../", // Set rootDir to the monorepo root + "module": "ESNext", + "target": "ESNext" + }, + "include": ["src/**/*", "../core/src/**/*"], // Include both example-app and core source files + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json index c41b75a..579a2db 100644 --- a/package.json +++ b/package.json @@ -1,63 +1,21 @@ { "name": "phoenix-framework", - "module": "src/index.ts", - "type": "module", + "version": "1.0.0", + "private": true, + "workspaces": ["core", "example-app"], "scripts": { - "start": "bun run src/index.ts", - "lint": "eslint 'src/**/*.{ts,tsx}'", - "lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix", - "format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,scss,md}'", - "dev": "nodemon --exec bun run src/index.ts", - "test:e2e": "bun test e2e-tests" - }, - "peerDependencies": { - "typescript": "^5.4.5" - }, - "dependencies": { - "@typegoose/typegoose": "^12.5.0", - "apollo-server-express": "^3.13.0", - "bcrypt": "^5.1.1", - "bullmq": "^5.8.2", - "casbin": "^5.30.0", - "casbin-mongoose-adapter": "^5.3.1", - "dotenv": "^16.4.5", - "envalid": "^8.0.0", - "express": "^4.19.2", - "graphql": "^16.8.1", - "jsonwebtoken": "^9.0.2", - "kafkajs": "^2.2.4", - "mongoose": "^8.4.1", - "reflect-metadata": "^0.2.2", - "type-graphql": "^2.0.0-rc.1", - "typedi": "^0.10.0", - "winston": "^3.13.0", - "winston-daily-rotate-file": "^5.0.0" + "start": "cd example-app && bun run src/index.ts", + "dev": "cd example-app && nodemon --exec bun run src/index.ts", + "lint": "eslint 'core/src/**/*.{ts,tsx}' 'example-app/src/**/*.{ts,tsx}'", + "lint:fix": "eslint 'core/src/**/*.{ts,tsx}' 'example-app/src/**/*.{ts,tsx}' --fix", + "format": "prettier --write 'core/src/**/*.{ts,tsx,js,jsx,json,css,scss,md}' 'example-app/src/**/*.{ts,tsx,js,jsx,json,css,scss,md}'", + "test:e2e": "cd example-app && bun test e2e-tests" }, "devDependencies": { - "@types/bcrypt": "^5.0.2", - "@types/bun": "latest", - "@types/chai": "^4.3.16", - "@types/express": "^4.17.21", - "@types/jsonwebtoken": "^9.0.6", - "@types/node": "^20.14.2", - "@types/supertest": "^6.0.2", - "@typescript-eslint/eslint-plugin": "^5.0.0", - "@typescript-eslint/parser": "^5.0.0", - "chai": "^5.1.1", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", - "mongodb-memory-server": "^9.3.0", - "nodemon": "^3.1.3", "prettier": "^3.3.2", - "supertest": "^7.0.0", - "ts-mockito": "^2.6.1", - "ts-node": "^10.9.2" - }, - "overrides": { - "@types/express": "^4.17.21" - }, - "resolutions": { - "graphql": "^16.8.1" + "typescript": "^5.4.5" } } diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 77d4e88..0000000 --- a/src/index.ts +++ /dev/null @@ -1,104 +0,0 @@ -import 'reflect-metadata'; -import express, { type Application, type Request, type Response, type NextFunction } from 'express'; -import { ApolloServer } from 'apollo-server-express'; -import env from './config/config'; -import logger from './config/logger'; -import { authenticate } from './middleware/auth'; -import { isIntrospectionQuery } from './utils/introspection-check'; -import { shouldBypassAuth } from './utils/should-bypass-auth'; -import sanitizeLog from './sanitize-log'; -import { initializeSharedResources } from './shared'; -import { startWorker } from './worker'; -import { getEnforcer } from './rbac.ts'; -import type PluginLoader from './plugins/plugin-loader.ts'; - -const loggerCtx = { context: 'index' }; - -async function startServer(pluginLoader: PluginLoader) { - try { - const schema = await pluginLoader.createSchema(); - - const server = new ApolloServer({ - schema, - introspection: true, // Ensure introspection is enabled - context: async ({ req }) => ({ - user: req.user, // User object from middleware - enforcer: await getEnforcer(), // Casbin enforcer instance - pluginsContext: pluginLoader.context, // Add the global context here - }), - }); - - await server.start(); - - const app: Application = express(); - - app.use(express.json()); - - // Middleware to conditionally authenticate user and set user context - app.use('/graphql', (req: Request, res: Response, next: NextFunction) => { - const reqInfo = { - url: req.url, - method: req.method, - ip: req.ip, - headers: req.headers, - operation: {}, - }; - - if (req.body && req.body.query) { - if (isIntrospectionQuery(req.body.query)) { - logger.verbose('Bypassing authentication for introspection query', loggerCtx); - return next(); // Bypass authentication for introspection queries - } - - if (shouldBypassAuth(req.body.query)) { - logger.verbose(`Bypassing authentication due to excluded operation`, loggerCtx); - return next(); // Bypass authentication for this request - } - - try { - // If no operation bypasses authentication, apply authentication middleware - authenticate(req, res, next); - } catch (error) { - logger.error('Error parsing GraphQL query:', { error, query: req.body.query }); - authenticate(req, res, next); - } - } else { - // If there is no query in the request body, continue with authentication - authenticate(req, res, next); - } - const sanitizedReqInfo = sanitizeLog(reqInfo); - const logLine = JSON.stringify(sanitizedReqInfo, null, 0); - logger.verbose(`reqInfo: ${logLine}`, loggerCtx); - }); - - server.applyMiddleware({ app }); - - const port = env.PORT; - app.listen(port, () => { - logger.info(`Server is running at http://localhost:${port}${server.graphqlPath}`, { context: 'index' }); - }); - } catch (error) { - logger.error('Failed to start server:', error, loggerCtx); - } -} - -async function startApp() { - const pluginLoader = await initializeSharedResources(); - - switch (env.MODE) { - case 'server': - await startServer(pluginLoader); - break; - case 'worker': - await startWorker(pluginLoader); - break; - case 'dev': - await startServer(pluginLoader); - await startWorker(pluginLoader, true); // Pass a flag to indicate dev mode - break; - default: - logger.error('Unknown mode specified. Please set MODE to "server", "worker", or "dev".', loggerCtx); - } -} - -startApp(); diff --git a/src/plugins/cart-plugin/resolvers/inputs/item-input.ts b/src/plugins/cart-plugin/resolvers/inputs/item-input.ts deleted file mode 100644 index 79dbcd7..0000000 --- a/src/plugins/cart-plugin/resolvers/inputs/item-input.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { InputType, Field, Int, Float } from 'type-graphql'; -import { prop } from '@typegoose/typegoose'; - -@InputType() -export class ItemInput { - @Field() - @prop({ required: true }) - public name!: string; - - @Field() - @prop({ required: true }) - public description!: string; - - @Field() - @prop({ required: true }) - public productId!: string; - - @Field(() => Int) - @prop({ required: true }) - public quantity!: number; - - @Field(() => Float) - @prop({ required: true }) - public price!: number; -} diff --git a/src/shared.ts b/src/shared.ts deleted file mode 100644 index a8b2a22..0000000 --- a/src/shared.ts +++ /dev/null @@ -1,25 +0,0 @@ -// src/shared.ts -import { connectToDatabase } from './config/database'; -import { initEnforcer } from './rbac'; -import { bootstrap } from './plugins/auth-plugin/bootstrap'; -import PluginLoader from './plugins/plugin-loader'; -import logger from './config/logger'; - -const loggerCtx = { context: 'shared' }; - -export async function initializeSharedResources() { - await connectToDatabase(); - await initEnforcer(); // Initialize Casbin - await bootstrap(); // Bootstrap the application with a superuser - - const pluginLoader = new PluginLoader(); - pluginLoader.loadPlugins(); - - // Register models before initializing plugins - pluginLoader.registerModels(); - - // Initialize plugins (extend models and resolvers) - pluginLoader.initializePlugins(); - - return pluginLoader; -} diff --git a/src/worker.ts b/src/worker.ts deleted file mode 100644 index 263be9e..0000000 --- a/src/worker.ts +++ /dev/null @@ -1,20 +0,0 @@ - -import logger from './config/logger'; -import { initializeSharedResources } from './shared'; -import type PluginLoader from './plugins/plugin-loader'; - -const loggerCtx = { context: 'worker' }; - -export async function startWorker(pluginLoader: PluginLoader, isDevMode = false) { - try { - if (!isDevMode) { - pluginLoader = await initializeSharedResources(); - } - - pluginLoader.initializeQueues(); - - logger.info('Worker started and ready to process jobs', loggerCtx); - } catch (error) { - logger.error('Failed to start worker:', error, loggerCtx); - } -} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..0eb671a --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "baseUrl": ".", + "paths": { + "@phoenix-framework/core/*": ["core/src/*"] + } + }, + "include": ["core/src/**/*.ts", "example-app/src/**/*.ts"] +}