Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: split app from framework #37

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[workspace]
packages = ["core", "example-app"]
65 changes: 65 additions & 0 deletions core/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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();
Expand Down Expand Up @@ -82,4 +81,4 @@ class KafkaEventService {
// Register the service with typedi
Container.set(KafkaEventService, new KafkaEventService());

export default KafkaEventService;
export { KafkaEventService };
4 changes: 4 additions & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './plugins/plugin-loader';
export * from './plugins/global-context';
export * from './plugins/plugin-interface';
export * from './plugins/plugins-list';
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
File renamed without changes.
38 changes: 21 additions & 17 deletions src/plugins/plugin-loader.ts → core/src/plugins/plugin-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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),
Expand Down Expand Up @@ -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<void> {
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<void> {
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);
}
}

Expand All @@ -105,7 +109,7 @@ class PluginLoader {
return await buildSchema({
resolvers: allResolvers as unknown as NonEmptyArray<Function>,
container: Container,
emitSchemaFile: path.resolve(__dirname, '../../schema.graphql'),
emitSchemaFile: true,
});
} catch (error) {
logger.error(`Error building schema: ${error}`, loggerCtx);
Expand All @@ -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;
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
97 changes: 97 additions & 0 deletions core/src/server.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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`);
}
38 changes: 38 additions & 0 deletions core/src/shared.ts
Original file line number Diff line number Diff line change
@@ -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<PluginLoader> {
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<string[]> {
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 };
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
23 changes: 23 additions & 0 deletions core/src/worker.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
10 changes: 10 additions & 0 deletions core/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
10 changes: 10 additions & 0 deletions example-app/.env.example
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions example-app/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading