serverus/server.ts

199 lines
5.8 KiB
TypeScript

/**
* SERVERUS SERVER module
* @module
*/
import * as colors from '@std/fmt/colors';
import * as fs from '@std/fs';
import * as path from '@std/path';
const DEFAULT_HANDLER_DIRECTORIES = [path.resolve(path.join(path.dirname(path.fromFileUrl(import.meta.url)), 'handlers'))];
/** A `HANDLER` must take a `Request` and return a `Response` if it can handle it. */
type HANDLER = (request: Request) => Promise<Response | null | undefined> | Response | null | undefined;
/** A `LOGGER` must take a `Request`, a `Response`, and a `processing_time` and log it. */
type LOGGER = (request: Request, response: Response, processing_time: number) => void | Promise<void>;
/** A `HANDLER_MODULE` must export a default method and may export an unload method to be called at shutdown. */
interface HANDLER_MODULE {
default: HANDLER;
unload?: () => void;
}
/**
* Interface defining the configuration for a 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;
};
/**
* 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
};
/**
* Default 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')}`
);
}
/**
* serverus SERVER
*
* Loads all handlers found in the [semi-]colon separated list of directories in
*/
export class SERVER {
private options: SERVER_OPTIONS;
private server: Deno.HttpServer | undefined;
private controller: AbortController | undefined;
private shutdown_binding: (() => void) | undefined;
private handlers: HANDLER_MODULE[];
/**
* @param {SERVER_OPTIONS} (optional) options to configure the server
*/
constructor(options?: SERVER_OPTIONS) {
this.options = {
...DEFAULT_SERVER_OPTIONS,
...(options ?? {})
};
this.handlers = [];
}
private shutdown() {
this.controller?.abort();
}
/**
* Start the server.
*
* @returns {Promise<SERVER>}
*/
public async start(): Promise<SERVER> {
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) => path.resolve(dir)) ??
DEFAULT_HANDLER_DIRECTORIES;
for (const handler_directory of HANDLERS_DIRECTORIES) {
const resolved_handler_directory_glob = path.resolve(path.join(handler_directory, '*.ts'));
for await (const globbed_record of fs.expandGlob(resolved_handler_directory_glob)) {
if (!globbed_record.isFile) {
continue;
}
const import_path = new URL('file://' + globbed_record.path, import.meta.url).toString();
const handler_module: HANDLER_MODULE = await import(import_path);
if (typeof handler_module.default !== 'function') {
console.warn(`Could not load handler, no default exported function: ${globbed_record.path}`);
continue;
}
this.handlers.push(handler_module);
}
}
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<Response> => {
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);
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.shutdown.bind(this);
Deno.addSignalListener('SIGTERM', this.shutdown_binding);
Deno.addSignalListener('SIGINT', this.shutdown_binding);
return this;
}
/**
* Stop the server
*/
public async stop(): Promise<void> {
if (this.server) {
this.controller?.abort();
await this.server.shutdown();
if (this.shutdown_binding) {
Deno.removeSignalListener('SIGTERM', this.shutdown_binding);
Deno.removeSignalListener('SIGINT', this.shutdown_binding);
}
}
this.controller = undefined;
this.server = undefined;
this.shutdown_binding = undefined;
for (const handler_module of this.handlers) {
if (typeof handler_module.unload === 'function') {
handler_module.unload();
}
}
this.handlers = [];
}
}