/** * SERVERUS SERVER module * @module */ import * as colors from '@std/fmt/colors'; import * as path from '@std/path'; const EVENTS_TO_SHUTDOWN_ON: Deno.Signal[] = ['SIGTERM', 'SIGINT']; const DEFAULT_HANDLER_DIRECTORIES = [import.meta.resolve('./handlers')]; /** * @type HANDLER Takes a `Request` and returns a `Response` if it can properly handle the request. */ type HANDLER = (request: Request, server: SERVER) => Promise | Response | null | undefined; /** * @type LOGGER Takes a `Request`, `Response`, and a `processing_time` in ms to be logged. */ type LOGGER = (request: Request, response: Response, processing_time: number) => void | Promise; /** * @interface HANDLER_MODULE Handler modules should export a default method (handler) and an optional `unload` method to be called at shutdown. */ interface HANDLER_MODULE { default: HANDLER; unload?: () => void | Promise; } /** * @type SERVER_OPTIONS Specifies options for creating the SERVERUS server. * * @property {string} [hostname='localhost'] - hostname to bind to * @property {number} [port=8000] - port to bind to * @property {logging} [logging=true] - true/false or a LOGGER instance, default: true (uses default logging) */ export type SERVER_OPTIONS = { hostname?: string; port?: number; logging?: boolean | LOGGER; root?: string; watch?: boolean; }; /** * Default options for a serverus server. * * @property {string} hostname - localhost * @property {number} port - 8000 * @property {boolean} logging - true (use default logger) */ export const DEFAULT_SERVER_OPTIONS: SERVER_OPTIONS = { hostname: 'localhost', port: 8000, logging: true }; /** * @method LOG_REQUEST Default request logger. * * @param {Request} request - the incoming request * @param {Response} response - the outgoing response * @param {number} time - the elapsed time in ms since the request was received */ function LOG_REQUEST(request: Request, response: Response, time: number) { const c = response.status >= 500 ? colors.red : response.status >= 400 ? colors.yellow : colors.green; const u = new URL(request.url); const qs = u.searchParams.toString(); console.log( `${c(request.method)} ${colors.gray(`(${response.status})`)} - ${ colors.cyan( `${u.pathname}${qs ? '?' + qs : ''}` ) } - ${colors.bold(String(time) + 'ms')}` ); } /** * @class SERVER Loads all handlers found in the [semi-]colon separated list of directories in `SERVERUS_ROOT`. */ export class SERVER { private options: SERVER_OPTIONS; private server: Deno.HttpServer | undefined; private controller: AbortController | undefined; private shutdown_binding: (() => void) | undefined; private original_directory: string | undefined; private event_listeners: Record; /** * @member handlers The HANDLER_MODULEs loaded for this server. */ public handlers: HANDLER_MODULE[]; /** * @param {SERVER_OPTIONS} (optional) options to configure the server */ constructor(options?: SERVER_OPTIONS) { this.options = { ...DEFAULT_SERVER_OPTIONS, ...(options ?? {}) }; this.handlers = []; this.event_listeners = {}; } /** * Start the server. * * @returns {Promise} */ public async start(): Promise { const root = this.options.root ?? Deno.env.get('SERVERUS_ROOT'); if (typeof root === 'string') { this.original_directory = Deno.cwd(); Deno.chdir(path.resolve(root)); } this.controller = new AbortController(); const { signal } = this.controller; const HANDLERS_DIRECTORIES: string[] = Deno.env.get('SERVERUS_HANDLERS') ?.split(/[;]/g) ?.filter((dir) => dir.length > 0) ?.map((dir) => { return dir.includes('://') ? dir : path.resolve(dir); }) ?? DEFAULT_HANDLER_DIRECTORIES; for (const handler_directory of HANDLERS_DIRECTORIES) { const index_file = path.join(handler_directory, 'index.ts'); try { const handlers_index = await import(index_file); for (const handler_name of Object.keys(handlers_index.default)) { const handler_module: HANDLER_MODULE = handlers_index.default[handler_name]; if (typeof handler_module?.default !== 'function') { console.warn(`Could not load handler "${handler_name}" - no default exported function`); continue; } this.handlers.push(handler_module); } } catch (error) { if (error instanceof Deno.errors.NotFound) { console.warn(`Could not load handler index from: ${index_file}`); continue; } throw error; } } if (this.handlers.length === 0) { throw new Error(`Could not load any handlers from: ${HANDLERS_DIRECTORIES.join('; ')}`, { cause: 'no_handlers_loaded' }); } this.server = Deno.serve( { port: this.options.port ?? DEFAULT_SERVER_OPTIONS.port, hostname: this.options.hostname ?? DEFAULT_SERVER_OPTIONS.hostname, onError: (error: unknown) => { console.error(error); return Response.json({ error: { message: (error as Error).message } }, { status: 500 }); }, signal }, async (request: Request): Promise => { const request_time = Date.now(); const logger: LOGGER | undefined = typeof this.options.logging === 'function' ? this.options.logging : (this.options.logging ? LOG_REQUEST : undefined); for (const handler_module of this.handlers) { const response = await handler_module.default(request, this); if (response) { logger?.(request, response, Date.now() - request_time); return response; } } const not_found = Response.json( { error: { message: 'Not found', cause: 'not_found' } }, { status: 404 } ); logger?.(request, not_found, Date.now() - request_time); return not_found; } ); this.shutdown_binding = this.stop.bind(this); for (const event of EVENTS_TO_SHUTDOWN_ON) { Deno.addSignalListener(event, this.shutdown_binding); } // if (this.options.watch) { // Deno.watchFs; // } this.emit('started', {}); return this; } /** * Stop the server */ public async stop(): Promise { this.emit('stopping', {}); if (this.server) { this.server.finished.finally(() => { if (this.shutdown_binding) { for (const event of EVENTS_TO_SHUTDOWN_ON) { Deno.removeSignalListener(event, this.shutdown_binding); } } this.shutdown_binding = undefined; }); this.controller?.abort(); await this.server.shutdown(); await this.server.finished; } if (this.original_directory) { Deno.chdir(this.original_directory); } this.controller = undefined; this.server = undefined; this.original_directory = undefined; for (const handler_module of this.handlers) { if (typeof handler_module.unload === 'function') { await handler_module.unload(); } } this.handlers = []; this.emit('stopped', {}); } /** * Add an event listener. * * @param {string} event The event to listen for. * @param {(event_data: any) => void} handler The handler for the event. */ public on(event: string, handler: (event_data: any) => void) { const listeners: ((event: any) => void)[] = this.event_listeners[event] = this.event_listeners[event] ?? []; if (!listeners.includes(handler)) { listeners.push(handler); } if (Deno.env.get('SERVERUS_LOG_EVENTS')) { console.dir({ on: { event, handler }, listeners }); } } /** * Remove an event listener. * * @param {string} event The event that was listened to. * @param {(event_data: any) => void} handler The handler that was registered that should be removed. */ public off(event: string, handler: (event_data: any) => void) { const listeners: ((event: any) => void)[] = this.event_listeners[event] = this.event_listeners[event] ?? []; if (listeners.includes(handler)) { listeners.splice(listeners.indexOf(handler), 1); } if (Deno.env.get('SERVERUS_LOG_EVENTS')) { console.dir({ off: { event: event, handler }, listeners }); } } public emit(event_name: string, event_data: any) { const listeners: ((event: any) => void)[] = this.event_listeners[event_name] = this.event_listeners[event_name] ?? []; const wildcard_listeners: ((event: any) => void)[] = this.event_listeners['*'] = this.event_listeners['*'] ?? []; const all_listeners: ((event: any) => void)[] = [...listeners, ...wildcard_listeners]; if (Deno.env.get('SERVERUS_LOG_EVENTS')) { console.dir({ emitting: { event_name, event_data, listeners, wildcard_listeners } }); } for (const listener of all_listeners) { listener(event_data); } } }