diff --git a/packages/api-server/src/api-client-request.ts b/packages/api-server/src/api-client-request.ts index 59eb8d9..2867460 100644 --- a/packages/api-server/src/api-client-request.ts +++ b/packages/api-server/src/api-client-request.ts @@ -1,28 +1,17 @@ import {createLogger} from '@alwatr/logger'; -import type {HttpMethod, RouteHandler} from './type.js'; -import type {IncomingMessage} from 'node:http'; +import {NanotronServerResponse} from './api-server-response.js'; -/** - * Configuration options for the Nanotron Api Client Request. - */ -export interface NanotronClientRequestConfig { - /** - * A prefix to be added to the beginning of the `url` of all defined routes. - * - * @default '/api/' - */ - prefix: `/${string}/` | '/'; -} +import type {DefineRouteOption, NativeClientRequest, NativeServerResponse} from './type.js'; +import type {NanotronUrl} from './url.js'; +import type {Dictionary} from '@alwatr/type-helper'; export class NanotronClientRequest { - protected static versionPattern_ = new RegExp('^/v[0-9]+/'); - - readonly url; + readonly url: NanotronUrl; - readonly method; + readonly serverResponse: NanotronServerResponse; - readonly raw_; + readonly routeOption: DefineRouteOption | null; /** * A flag to indicate if the running handlers queue has been terminated. @@ -38,33 +27,28 @@ export class NanotronClientRequest { */ terminatedHandlers?: true; - readonly preHandlers_: RouteHandler[] = []; + readonly sharedMeta: Dictionary = {}; - protected readonly logger_; + readonly raw_: NativeClientRequest; - protected readonly config_; + protected readonly logger_; constructor( - clientRequest: IncomingMessage, - config: NanotronClientRequestConfig, + url: NanotronUrl, + nativeClientRequest: NativeClientRequest, + nativeServerResponse: NativeServerResponse, + routeOption: DefineRouteOption | null, ) { - // Store the raw request object and configuration. - this.config_ = config; - this.raw_ = clientRequest; - - // Parse request method. - this.method = (this.raw_.method ?? 'GET').toUpperCase() as HttpMethod; - - // Parse request URL. - let url = this.raw_.url ?? ''; - if (this.config_.prefix !== '/' && url.indexOf(this.config_.prefix) === 0) { - url = url.slice(this.config_.prefix.length - 1); - } - url = url.replace(NanotronClientRequest.versionPattern_, '/'); - this.url = new URL(url, 'http://hostname/'); + // Store public properties. + this.raw_ = nativeClientRequest; + this.url = url; + this.routeOption = routeOption; // Create logger. this.logger_ = createLogger('nt-client-request'); // TODO: add client ip - this.logger_.logMethodArgs?.('new', {method: this.method, url: this.url.pathname}); + this.logger_.logMethodArgs?.('new', url.debugId); + + // Create server response. + this.serverResponse = new NanotronServerResponse(this, nativeServerResponse); } } diff --git a/packages/api-server/src/api-server-response.ts b/packages/api-server/src/api-server-response.ts index c14e8e3..5f47817 100644 --- a/packages/api-server/src/api-server-response.ts +++ b/packages/api-server/src/api-server-response.ts @@ -3,9 +3,8 @@ import {createLogger} from '@alwatr/logger'; import {type HttpStatusCode, HttpStatusCodes, HttpStatusMessages} from './const.js'; import type {NanotronClientRequest} from './api-client-request.js'; -import type {HttpResponseHeaders, ErrorResponse} from './type.js'; +import type {HttpResponseHeaders, ErrorResponse, NativeServerResponse} from './type.js'; import type {Json} from '@alwatr/type-helper'; -import type {ServerResponse} from 'node:http'; /** * Configuration options for the Nanotron Api Server Response. @@ -15,30 +14,27 @@ export interface NanotronServerResponseConfig { } export class NanotronServerResponse { - readonly raw_; + readonly clientRequest: NanotronClientRequest; + + readonly raw_: NativeServerResponse; readonly headers: HttpResponseHeaders; protected readonly logger_; - protected readonly config_; - protected hasBeenSent_ = false; get hasBeenSent(): boolean { return this.hasBeenSent_; } - constructor( - serverResponse: ServerResponse, - config: NanotronServerResponseConfig, - ) { - // Store the raw request object and configuration. - this.config_ = config; - this.raw_ = serverResponse; + constructor(nanotronClientRequest: NanotronClientRequest, nativeServerResponse: NativeServerResponse) { + // Store public properties. + this.clientRequest = nanotronClientRequest; + this.raw_ = nativeServerResponse; // Create logger. this.logger_ = createLogger('nt-server-response'); // TODO: add client ip - this.logger_.logMethod?.('new'); + this.logger_.logMethodArgs?.('new', this.clientRequest.url.debugId); // Set default reply headers. this.headers = { @@ -64,14 +60,14 @@ export class NanotronServerResponse { replyErrorResponse(errorResponse: ErrorResponse): void { this.logger_.logMethod?.('replyErrorResponse'); - this.config_.clientRequest.terminatedHandlers = true; + this.clientRequest.terminatedHandlers = true; this.replyJson(errorResponse); } replyError(error?: Error | string | Json | unknown): void { this.logger_.logMethodArgs?.('replyError', {error}); - this.config_.clientRequest.terminatedHandlers = true; + this.clientRequest.terminatedHandlers = true; let statusCode = this.statusCode; if (statusCode < HttpStatusCodes.Error_Client_400_Bad_Request) { @@ -81,13 +77,10 @@ export class NanotronServerResponse { if (error instanceof Error) { this.replyJson({ ok: false, - errorCode: error.name === 'Error' - ? ('error_' + statusCode) as Lowercase - : (error.name + '').toLowerCase(), + errorCode: error.name === 'Error' ? (('error_' + statusCode) as Lowercase) : (error.name + '').toLowerCase(), errorMessage: error.message, }); } - else if (typeof error === 'string') { this.replyJson({ ok: false, @@ -95,16 +88,14 @@ export class NanotronServerResponse { errorMessage: error, }); } - else if (typeof error === 'object' && error !== null) { this.replyJson(error as Json); } - else { this.replyJson({ ok: false, errorCode: ('error_' + statusCode) as Lowercase, - errorMessage: HttpStatusMessages[statusCode] + errorMessage: HttpStatusMessages[statusCode], } as ErrorResponse); } } @@ -117,10 +108,7 @@ export class NanotronServerResponse { responseString = JSON.stringify(responseJson); } catch (error) { - this.logger_.error('replyJson', 'reply_json_stringify_failed', error, { - url: this.config_.clientRequest.url.pathname, - method: this.config_.clientRequest.method, - }); + this.logger_.error('replyJson', 'reply_json_stringify_failed', error, this.clientRequest.url.debugId); this.statusCode = HttpStatusCodes.Error_Server_500_Internal_Server_Error; responseString = JSON.stringify({ ok: false, @@ -134,10 +122,7 @@ export class NanotronServerResponse { } reply(context: string | Buffer): void { - this.logger_.logMethodArgs?.('reply', { - url: this.config_.clientRequest.url.pathname, - method: this.config_.clientRequest.method, - }); + this.logger_.logMethodArgs?.('reply', this.clientRequest.url.debugId); if (this.raw_.writableFinished && this.hasBeenSent_ === false) { // The response has already been sent by direct access to the server api. @@ -147,8 +132,7 @@ export class NanotronServerResponse { if (this.hasBeenSent_) { this.logger_.accident('reply', 'reply_already_sent', { - url: this.config_.clientRequest.url.pathname, - method: this.config_.clientRequest.method, + url: this.clientRequest.url.debugId, replySent: this.hasBeenSent_, writableFinished: this.raw_.writableFinished, }); @@ -166,13 +150,9 @@ export class NanotronServerResponse { this.applyHeaders_(); this.raw_.end(context, 'binary'); - } catch (error) { - this.logger_.error('reply', 'server_response_error', error, { - url: this.config_.clientRequest.url.pathname, - method: this.config_.clientRequest.method, - }); + this.logger_.error('reply', 'server_response_error', error, this.clientRequest.url.debugId); this.hasBeenSent_ = false; } } diff --git a/packages/api-server/src/api-server.ts b/packages/api-server/src/api-server.ts index 99bc2ad..8bf5ebb 100644 --- a/packages/api-server/src/api-server.ts +++ b/packages/api-server/src/api-server.ts @@ -1,12 +1,12 @@ -import {createServer, IncomingMessage, ServerResponse} from 'node:http'; +import {createServer} from 'node:http'; import {createLogger} from '@alwatr/logger'; import {NanotronClientRequest} from './api-client-request.js'; -import { NanotronServerResponse } from './api-server-response.js'; import {HttpStatusCodes} from './const.js'; +import {NanotronUrl} from './url.js'; -import type {HttpMethod, MatchType, RouteHandler} from './type.js'; +import type {DefineRouteOption, MatchType, NativeClientRequest, NativeServerResponse} from './type.js'; import type {Dictionary} from '@alwatr/type-helper'; import type {Duplex} from 'node:stream'; @@ -71,43 +71,13 @@ export interface NanotronApiServerConfig { * @default '/api/' */ prefix?: `/${string}/` | '/'; -} -/** - * Configuration options for defining a route. - */ -export interface DefineRouteOption { /** - * The HTTP method for this route. - */ - method: HttpMethod; - - /** - * The URL path for this route. - */ - url: string; - - /** - * Specifies how the `url` should be matched against incoming requests. + * The maximum size of the request body in bytes. * - * @default 'exact' - */ - matchType?: MatchType; - - /** - * The functions call before the main handler. - */ - preHandlers?: RouteHandler[]; - - /** - * The function to handle requests to this route. + * @default `1_048_576` (1MiB) */ - handler: RouteHandler; - - /** - * The functions call after the main handler. - */ - postHandlers?: RouteHandler[]; + bodyLimit?: number; } export class NanotronApiServer { @@ -120,9 +90,10 @@ export class NanotronApiServer { healthRoute: true, allowAllOrigin: false, prefix: '/api/', + bodyLimit: 1_048_576, // 1MiB }; - protected readonly config_; + readonly config_; protected readonly logger_; readonly httpServer; @@ -181,26 +152,26 @@ export class NanotronApiServer { this.httpServer.close(); } - protected getRouteOption_(option: Required>): Required | null { - this.logger_.logMethodArgs?.('getRouteOption_', option); + protected getRouteOption_(url: NanotronUrl): Required | null { + this.logger_.logMethod?.('getRouteOption_'); if ( - Object.hasOwn(this.routeHandlerList__.exact, option.method) && - Object.hasOwn(this.routeHandlerList__.exact[option.method], option.url) + Object.hasOwn(this.routeHandlerList__.exact, url.method) && + Object.hasOwn(this.routeHandlerList__.exact[url.method], url.pathname) ) { - return this.routeHandlerList__.exact[option.method][option.url]; + return this.routeHandlerList__.exact[url.method][url.pathname]; } - if (Object.hasOwn(this.routeHandlerList__.startsWith, option.method)) { - const routeList = this.routeHandlerList__.startsWith[option.method]; - for (const url in routeList) { - if (url.indexOf(option.url) === 0) { - return routeList[url]; + if (Object.hasOwn(this.routeHandlerList__.startsWith, url.method)) { + const routeList = this.routeHandlerList__.startsWith[url.method]; + for (const pathname in routeList) { + if (pathname.indexOf(url.pathname) === 0) { + return routeList[pathname]; } } } - this.logger_.incident?.('getRouteOption_', 'route_not_found', option); + this.logger_.incident?.('getRouteOption_', 'route_not_found', {method: url.method, url: url.pathname}); return null; } @@ -224,6 +195,7 @@ export class NanotronApiServer { matchType: 'exact', preHandlers: [], postHandlers: [], + bodyLimit: this.config_.bodyLimit, ...option, }; this.logger_.logMethodArgs?.('defineRoute', {...option_, handler: 'function'}); @@ -247,68 +219,54 @@ export class NanotronApiServer { socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); } - protected handleHttpError_(serverResponse: NanotronServerResponse, error?: unknown): void { - this.logger_.logMethod?.('handleHttpError_'); - // TODO: custom error template by the user. - serverResponse.replyError(error); - } - - protected async handleClientRequest_(clientRequest: IncomingMessage, serverResponse: ServerResponse): Promise { + protected async handleClientRequest_( + nativeClientRequest: NativeClientRequest, + nativeServerResponse: NativeServerResponse, + ): Promise { this.logger_.logMethod?.('handleClientRequest_'); - if (clientRequest.url === undefined) { + if (nativeClientRequest.url === undefined) { this.logger_.accident('handleClientRequest_', 'http_server_url_undefined'); return; } - if (clientRequest.method === undefined) { + if (nativeClientRequest.method === undefined) { this.logger_.accident('handleClientRequest_', 'http_server_method_undefined'); return; } - const nanotronClientRequest = new NanotronClientRequest(clientRequest, {prefix: this.config_.prefix}); - const nanotronServerResponse = new NanotronServerResponse(serverResponse, {clientRequest: nanotronClientRequest}); + const url = new NanotronUrl(nativeClientRequest, this.config_.prefix); - const routeOption = this.getRouteOption_({ - method: nanotronClientRequest.method, - url: nanotronClientRequest.url.pathname, - }); + const routeOption = this.getRouteOption_(url); + + const connection = new NanotronClientRequest(url, nativeClientRequest, nativeServerResponse, routeOption); if (routeOption === null) { - nanotronServerResponse.statusCode = HttpStatusCodes.Error_Client_404_Not_Found; - return this.handleHttpError_(nanotronServerResponse); + connection.serverResponse.statusCode = HttpStatusCodes.Error_Client_404_Not_Found; + connection.serverResponse.replyError(); + return; } - const sharedMeta = {}; - try { - for (const handler of nanotronClientRequest.preHandlers_) { - if (nanotronClientRequest.terminatedHandlers === true) return; - await handler(nanotronClientRequest, nanotronServerResponse, sharedMeta); - } - for (const handler of routeOption.preHandlers) { - if (nanotronClientRequest.terminatedHandlers === true) return; - await handler(nanotronClientRequest, nanotronServerResponse, sharedMeta); + if (connection.terminatedHandlers === true) return; + await handler(connection, connection.serverResponse, connection.sharedMeta); } - await routeOption.handler(nanotronClientRequest, nanotronServerResponse, sharedMeta); + await routeOption.handler(connection, connection.serverResponse, connection.sharedMeta); for (const handler of routeOption.postHandlers) { - if (nanotronClientRequest.terminatedHandlers === true) return; - await handler(nanotronClientRequest, nanotronServerResponse, sharedMeta); + if (connection.terminatedHandlers === true) return; + await handler(connection, connection.serverResponse, connection.sharedMeta); } } catch (error) { - this.logger_.error('handleClientRequest_', 'route_handler_error', error, { - url: nanotronClientRequest.url.pathname, - method: nanotronClientRequest.method, - }); + this.logger_.error('handleClientRequest_', 'route_handler_error', error, url.debugId); - if (nanotronServerResponse.statusCode < HttpStatusCodes.Error_Client_400_Bad_Request) { - nanotronServerResponse.statusCode = HttpStatusCodes.Error_Server_500_Internal_Server_Error; + if (connection.serverResponse.statusCode < HttpStatusCodes.Error_Client_400_Bad_Request) { + connection.serverResponse.statusCode = HttpStatusCodes.Error_Server_500_Internal_Server_Error; } - this.handleHttpError_(nanotronServerResponse, error); + connection.serverResponse.replyError(error); } // TODO: handled open remained connections. diff --git a/packages/api-server/src/main.ts b/packages/api-server/src/main.ts index f7d7c81..eaeff87 100644 --- a/packages/api-server/src/main.ts +++ b/packages/api-server/src/main.ts @@ -1,5 +1,6 @@ export * from './api-server.js'; -export * from './api-client-request.js'; -export * from './api-server-response.js'; +export type {NanotronClientRequest} from './api-client-request.js'; +export type {NanotronServerResponse} from './api-server-response.js'; +export type {NanotronUrl} from './url.js'; export * from './type.js'; export * from './const.js'; diff --git a/packages/api-server/src/type.ts b/packages/api-server/src/type.ts index a5b7b2d..2a0c17a 100644 --- a/packages/api-server/src/type.ts +++ b/packages/api-server/src/type.ts @@ -1,6 +1,7 @@ -import type { NanotronClientRequest } from './api-client-request.js'; -import type { NanotronServerResponse } from './api-server-response.js'; -import type { Dictionary, Json, MaybePromise } from '@alwatr/type-helper'; +import type {NanotronClientRequest} from './api-client-request.js'; +import type {NanotronServerResponse} from './api-server-response.js'; +import type {Dictionary, Json, MaybePromise} from '@alwatr/type-helper'; +import type {IncomingMessage, ServerResponse} from 'node:http'; declare module 'http' { interface IncomingHttpHeaders { @@ -12,6 +13,66 @@ export type MatchType = 'exact' | 'startsWith'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD' | 'CONNECT' | 'TRACE'; +export type ErrorResponse = { + ok: false; + errorCode: Lowercase; + errorMessage: string; + meta?: Json; +}; + +export type RouteHandler = ( + clientRequest: NanotronClientRequest, + serverResponse: NanotronServerResponse, + sharedMeta: Dictionary, +) => MaybePromise; + +export type NativeClientRequest = IncomingMessage; +export type NativeServerResponse = ServerResponse; + +/** + * Configuration options for defining a route. + */ +export interface DefineRouteOption { + /** + * The HTTP method for this route. + */ + method: HttpMethod; + + /** + * The URL path for this route. + */ + url: string; + + /** + * Specifies how the `url` should be matched against incoming requests. + * + * @default 'exact' + */ + matchType?: MatchType; + + /** + * The functions call before the main handler. + */ + preHandlers?: RouteHandler[]; + + /** + * The function to handle requests to this route. + */ + handler: RouteHandler; + + /** + * The functions call after the main handler. + */ + postHandlers?: RouteHandler[]; + + /** + * The maximum size of the request body in bytes. + * + * @default `1_048_576` (1MiB) or the value set in the server configuration. + */ + bodyLimit?: number; +} + /** * Represents the collection of HTTP response headers. */ @@ -24,12 +85,12 @@ export interface HttpResponseHeaders { /** * Specifies the time in seconds the object has been in a proxy cache. */ - 'age'?: string; + age?: string; /** * Lists the set of HTTP methods supported by the resource identified by the Request-URI. */ - 'allow'?: string; + allow?: string; /** * Specifies caching directives for both requests and responses. @@ -39,7 +100,7 @@ export interface HttpResponseHeaders { /** * Controls whether the network connection stays open after the current transaction. */ - 'connection'?: string; + connection?: string; /** * Suggests a filename for the downloaded resource or how the content should be displayed. @@ -79,17 +140,17 @@ export interface HttpResponseHeaders { /** * Indicates the date and time at which the message was originated. */ - 'date'?: string; + date?: string; /** * Provides the current value of the entity tag for the requested variant. */ - 'etag'?: string; + etag?: string; /** * Gives the date/time after which the response is considered stale. */ - 'expires'?: string; + expires?: string; /** * Indicates the date and time at which the origin server believes the variant was last modified. @@ -99,17 +160,17 @@ export interface HttpResponseHeaders { /** * Provides a list of URIs associated with the resource. */ - 'link'?: string; + link?: string; /** * Used in redirection, or when a new resource has been created. */ - 'location'?: string; + location?: string; /** * Used for backward compatibility with HTTP/1.0 caches. */ - 'pragma'?: string; + pragma?: string; /** * Requests authentication information from the client for a proxy server. @@ -124,7 +185,7 @@ export interface HttpResponseHeaders { /** * Contains information about the software used by the origin server to handle the request. */ - 'server'?: string; + server?: string; /** * Used to send cookies from the server to the user agent. @@ -139,7 +200,7 @@ export interface HttpResponseHeaders { /** * Allows the sender to include additional fields at the end of chunked messages. */ - 'trailer'?: string; + trailer?: string; /** * Specifies the form of encoding used to safely transfer the entity to the user. @@ -150,17 +211,17 @@ export interface HttpResponseHeaders { * Determines how to match future request headers to decide whether a cached response * can be used rather than requesting a fresh one from the origin server. */ - 'vary'?: string; + vary?: string; /** * Lists all intermediate proxies the message has traversed */ - 'via'?: string; + via?: string; /** * Contains additional information about the status or transformation of a message that might not be reflected in the status code. */ - 'warning'?: string; + warning?: string; /** * Indicates the authentication scheme that should be used to access the requested entity. @@ -170,16 +231,3 @@ export interface HttpResponseHeaders { // Additional headers can be added here as needed [headerName: Lowercase]: string | string[] | number | undefined; } - -export type ErrorResponse = { - ok: false; - errorCode: Lowercase; - errorMessage: string; - meta?: Json; -} - -export type RouteHandler = ( - clientRequest: NanotronClientRequest, - serverResponse: NanotronServerResponse, - sharedMeta: Dictionary, -) => MaybePromise; diff --git a/packages/api-server/src/url.ts b/packages/api-server/src/url.ts new file mode 100644 index 0000000..0c4fef1 --- /dev/null +++ b/packages/api-server/src/url.ts @@ -0,0 +1,25 @@ +import {URL} from 'node:url'; + +import type {HttpMethod} from './type.js'; +import type {IncomingMessage} from 'node:http'; + +export class NanotronUrl extends URL { + protected static versionPattern_ = new RegExp('^/v[0-9]+/'); + + readonly method: HttpMethod; + readonly debugId: string; + + constructor(clientRequest: IncomingMessage, prefix: `/${string}/` | '/') { + let url = clientRequest.url ?? ''; + if (prefix !== '/' && url.indexOf(prefix) === 0) { + url = url.slice(prefix.length - 1); // include `/` + } + url = url.replace(NanotronUrl.versionPattern_, '/'); + + super(url, 'http://hostname/'); + + this.method = (clientRequest.method ?? 'GET').toUpperCase() as HttpMethod; + + this.debugId = `[${this.method}]${this.pathname}`; + } +} diff --git a/packages/nanotron/demo/api-server.mjs b/packages/nanotron/demo/api-server.mjs index 3da5982..2fd6828 100644 --- a/packages/nanotron/demo/api-server.mjs +++ b/packages/nanotron/demo/api-server.mjs @@ -25,3 +25,14 @@ apiServer.defineRoute({ }); } }); + +apiServer.defineRoute({ + method: 'GET', + url: '/hello', + handler (connection) { + connection.serverResponse.replyJson({ + ok: true, + message: 'Hello :)', + }); + } +});