import { readdir } from 'fs/promises'; import { resolve as resolvePath } from 'path'; import LayRouting from './Routing/LayRouting'; import HtmlEngine from './Views/Html'; import DefaultLoader from './Views/DefaultLoader'; import BaseRouter from './Routing/BaseRouter'; import Logger from './Utils/Logger'; /** * Represents a LayServer instance that handles routing and server configuration. * @todo add multi domain support * @class */ export default class LayServer extends BaseRouter { controllers = new Map(); loaders = new Map(); proc = null; publicFiles; routing = null; options = new Map([ ['loaders', undefined], ['publicDir', undefined], ['viewsPath', undefined], ['preferIndexFiles', false], ['discoverPublicFiles', false], ['enforceTrailingSlash', false], ['laycLoading', false], ['allowRouteOverwrite', true], ]); /** * Creates a new instance of LayServer. * @param {Object} options - The options for configuring the LayServer. */ constructor(options = {}) { super(); if (typeof options == 'object') { const optionKeys = Object.getOwnPropertyNames(options); for (const key of optionKeys) { if (!this.options.has(key)) continue; if (key == 'loaders') continue; const valueType = typeof this.options.get(key); if ( typeof options[key] !== 'undefined' && (typeof options[key] == valueType || valueType == 'undefined') ) this.options.set(key, options[key]); } if (options.hasOwnProperty('loaders')) { if (options.loaders instanceof Map) { this.loaders = options.loaders; } } } this.routing = new LayRouting(this); if(options.router) { this.routing.addRouter(options.router); } if (options.hasOwnProperty('controllers')) { this.registerControllers(options.controllers); } if (options.publicFiles) this.addPublicFileRoutes(options.publicFiles); if ( !options.publicFiles && this.options.discoverPublicFiles && this.options.publicDir ) this.indexPublicFiles(this.options.publicDir); if (!this.options.viewsEngine && this.options.viewsPath) { this.options.viewsEngine = new HtmlEngine(); } } /** * Retrieves or sets the value of an option. * @param {string} key - The key of the option. * @param {*} [value] - The value to set for the option. If not provided, the current value of the option is returned. * @returns {*} - The current value of the option, or undefined if the option does not exist. */ option(key, value = undefined) { if (!this.options.has(key)) return undefined; if (value !== undefined) this.option.set(key, value); return this.options.get(key); } /** * Adds public file routes to the LayServer instance. * @param {Array} filePaths - An array of file paths to be added as public file routes. * @throws {Error} Throws an error if filePaths is not an array or undefined. */ addPublicFileRoutes(filePaths) { if (!filePaths) return; if (!Array.isArray(filePaths)) throw new Error( 'publicFiles option has to be an array or undefined' ); if (!this.publicFiles || !Array.isArray(this.publicFiles)) this.publicFiles = []; for (let path of filePaths) { if (typeof path != 'string') continue; this.publicFiles.push(path); } } /** * Indexes all public files stored as static routes. * @todo Implement this method. * @param {string} publicDir - The directory where the public files are stored. */ indexPublicFiles(publicDir) { Logger.error('@todo implement method `indexPublicFiles`') // this method is meant to try to iterate over all public files which are stored as static routes } /** * Registers the given controllers. * * @param {Function|Function[]} controllers - The controllers to register. */ registerControllers(controllers) { if (!Array.isArray(controllers)) controllers = [controllers]; for (let controller of controllers) { if (typeof controller != 'function') { Logger(`ignored ${controller} for not being a function`); continue; } // if the given controller is just an anonymous function defining the routes itself if (!controller?.prototype?.constructor?.name) { controller(this.routing); continue; } this.controllers.set( controller?.name, this.instantiateController(controller) ); } } /** * Instantiates a controller and sets its namespace in the route control. * @param {Object} controller - The controller to be instantiated. * @returns {Object} - The instantiated controller. */ instantiateController(controller) { this.routing.setNamespace(controller.namespace); const _temp = new controller(this.routing); if (controller.namespace) this.routing.setNamespace(null); return _temp; } /** * Checks if the server is running. * @returns {boolean} True if the server is running, false otherwise. */ isRunning() { proc != null; } /** * Handles an incoming request. * * @param {Request} req - The incoming request object. * @returns {Promise} - A promise that resolves to a response object. */ async handleRequest(req) { const url = new URL(req.url); if ( !url.pathname.endsWith('/') && Array.isArray(this.publicFiles) && this.publicFiles.length > 0 ) { let path = this.publicFiles.find( (v) => url.pathname.substring(1) === v ); if (path) { try { return new Response( Bun.file( resolvePath( this.options.get('publicDir') ?? `${import.meta.dir}/public`, path ) ) ); } catch (err) { Logger(err, import.meta.dir, 'public', path); return new Response('Internal Server Error', { status: 500, }); } } } if (this.options.enforceTrailingSlash && !url.pathname.endsWith('/')) { return Response.redirect(`${url.toString()}/`, 302); } let resp = await this.routing.handle(req); if (resp instanceof Response) return resp; return new Response('Not Found', { status: 404 }); } /** * Starts the server and listens for incoming requests on the specified port. * @param {number} [port=8080] - The port number to listen on. */ async listen(port = 8080, cb = () => {}) { try { this.proc = await Bun.serve({ port, development: Bun.env.BUN_ENV != 'production', fetch: this.handleRequest.bind(this), error(err) { Logger('where', err.stack); return new Response(`
${err}\n${err.stack}
`, { headers: { 'Content-Type': 'text/html', }, }); }, }); if (!this.proc) { throw new Error('something went wrong'); } if (!!cb && cb.constructor?.name.endsWith('Function')) { cb(this.proc); } } catch (err) { Logger.error('where', err.stack); } } /** * Retrieves the path of a view file based on the given filepath. * @param {string} filepath - The filepath of the view file. * @returns {Promise} - The path of the view file, or null if not found. */ async getViewPath(filepath) { let path = this.options.get('viewsPath'); if (!path.endsWith('/')) path += '/'; const filename = filepath.split('/').pop().toString(); const basedir = filepath.substring(0, filepath.indexOf(filename)); path += basedir; if (!path.endsWith('/')) path += '/'; const files = await readdir(path); for (let file of files) { console.log( file, file.substring(0, file.indexOf('.')), filename.substring(0, filename.indexOf('.')) || filename ); if ( (file.substring(0, file.indexOf('.')) || file) != (filename.substring(0, filename.indexOf('.')) || filename) ) continue; return `${path}${file}`; } return null; } /** * Loads a file from the specified filepath. * * @param {string} filepath - The path of the file to load. * @returns {DefaultLoader} - The loader for the file. */ async load(filepath) { const fullFilePath = await this.getViewPath(filepath); if (!fullFilePath) return new DefaultLoader(filepath); const filename = fullFilePath.split('/').pop().toString(); const ext = filename.substring(filename.indexOf('.') + 1); if (this.loaders.size > 0) { for (const keys of this.loaders.keys()) { if (!keys.includes(ext)) continue; return this.loaders.get(keys).handle(fullFilePath); } } return new DefaultLoader(filepath); } async add(path, cb) { this.routing.add(...arguments) } }