@alwatr/nanotron-api-server 4.10.1 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,179 @@
1
+ import {createLogger} from '@alwatr/logger';
2
+
3
+ import {type HttpStatusCode, HttpStatusCodes, HttpStatusMessages} from './const.js';
4
+
5
+ import type {NanotronClientRequest} from './api-client-request.js';
6
+ import type {HttpResponseHeaders, ErrorResponse, NativeServerResponse} from './type.js';
7
+
8
+ /**
9
+ * Configuration options for the Nanotron Api Server Response.
10
+ */
11
+ export interface NanotronServerResponseConfig {
12
+ clientRequest: NanotronClientRequest;
13
+ }
14
+
15
+ export class NanotronServerResponse {
16
+ public readonly clientRequest: NanotronClientRequest;
17
+
18
+ public readonly raw_: NativeServerResponse;
19
+
20
+ public readonly headers: HttpResponseHeaders;
21
+
22
+ protected readonly logger_;
23
+
24
+ protected hasBeenSent_ = false;
25
+ public get hasBeenSent(): boolean {
26
+ return this.hasBeenSent_;
27
+ }
28
+
29
+ constructor(nanotronClientRequest: NanotronClientRequest, nativeServerResponse: NativeServerResponse) {
30
+ // Store public properties.
31
+ this.clientRequest = nanotronClientRequest;
32
+ this.raw_ = nativeServerResponse;
33
+
34
+ // Create logger.
35
+ this.logger_ = createLogger(`nt-server-response(${this.clientRequest.remoteAddress})`);
36
+ this.logger_.logMethodArgs?.('new', this.clientRequest.url.debugId);
37
+
38
+ // Set default reply headers.
39
+ this.headers = {
40
+ server: 'Alwatr Nanotron',
41
+ 'content-type': 'text/plain charset=UTF-8',
42
+ };
43
+
44
+ const crossOrigin = this.clientRequest.routeOption?.crossOrigin;
45
+ if (crossOrigin?.enable === true) {
46
+ this.headers['access-control-allow-origin'] = crossOrigin.origin;
47
+ this.headers['access-control-allow-methods'] = crossOrigin.methods;
48
+ this.headers['access-control-allow-headers'] = crossOrigin.headers;
49
+ this.headers['access-control-max-age'] = crossOrigin.maxAge;
50
+ }
51
+ }
52
+
53
+ public get statusCode(): HttpStatusCode {
54
+ return this.raw_.statusCode as HttpStatusCode;
55
+ }
56
+
57
+ public set statusCode(value: HttpStatusCode) {
58
+ this.raw_.statusCode = value;
59
+ }
60
+
61
+ protected applyHeaders_() {
62
+ this.logger_.logMethodArgs?.('applyHeaders_', this.headers);
63
+ for (const key in this.headers) {
64
+ this.raw_.setHeader(key, this.headers[key as Lowercase<string>]!);
65
+ }
66
+ }
67
+
68
+ public replyErrorResponse(errorResponse: ErrorResponse): void {
69
+ this.logger_.logMethod?.('replyErrorResponse');
70
+ this.clientRequest.terminatedHandlers = true;
71
+ this.headers['content-type'] = 'application/json';
72
+ let meta = '';
73
+ if (errorResponse.meta !== undefined) {
74
+ const metaType = typeof errorResponse.meta;
75
+ if (metaType === 'string' || metaType === 'number' || metaType === 'boolean' || errorResponse.meta === null) {
76
+ meta = `,"meta":"${errorResponse.meta}"`;
77
+ }
78
+ else if (metaType === 'object') {
79
+ meta = `,"meta":${JSON.stringify(errorResponse.meta)}`;
80
+ }
81
+ }
82
+ const responseString = `{"ok":false,"errorCode":"${errorResponse.errorCode}","errorMessage":"${errorResponse.errorMessage}"${meta}}`;
83
+ this.reply(responseString);
84
+ }
85
+
86
+ public replyError(error?: Error | string | JsonObject | unknown): void {
87
+ this.logger_.logMethodArgs?.('replyError', {error});
88
+
89
+ this.clientRequest.terminatedHandlers = true;
90
+ let statusCode = this.statusCode;
91
+
92
+ if (statusCode < HttpStatusCodes.Error_Client_400_Bad_Request) {
93
+ this.statusCode = statusCode = 500;
94
+ }
95
+
96
+ if (error instanceof Error) {
97
+ this.replyErrorResponse({
98
+ ok: false,
99
+ errorCode: (error.name === 'Error' ? 'error_' + statusCode : (error.name + '').toLowerCase()) as Lowercase<string>,
100
+ errorMessage: error.message,
101
+ });
102
+ }
103
+ else if (typeof error === 'string') {
104
+ this.replyErrorResponse({
105
+ ok: false,
106
+ errorCode: ('error_' + statusCode) as Lowercase<string>,
107
+ errorMessage: error,
108
+ });
109
+ }
110
+ else if (typeof error === 'object' && error !== null) {
111
+ this.replyJson(error as JsonObject);
112
+ }
113
+ else {
114
+ this.replyErrorResponse({
115
+ ok: false,
116
+ errorCode: ('error_' + statusCode) as Lowercase<string>,
117
+ errorMessage: HttpStatusMessages[statusCode],
118
+ });
119
+ }
120
+ }
121
+
122
+ public replyJson(responseJson: JsonObject): void {
123
+ this.logger_.logMethodArgs?.('replyJson', {responseJson});
124
+
125
+ let responseString: string;
126
+ try {
127
+ responseString = JSON.stringify(responseJson);
128
+ }
129
+ catch (error) {
130
+ this.logger_.error('replyJson', 'reply_json_stringify_failed', error, this.clientRequest.url.debugId);
131
+ this.statusCode = HttpStatusCodes.Error_Server_500_Internal_Server_Error;
132
+ this.replyErrorResponse({
133
+ ok: false,
134
+ errorCode: 'reply_json_stringify_failed',
135
+ errorMessage: 'Failed to stringify response JSON.',
136
+ });
137
+ return;
138
+ }
139
+
140
+ this.headers['content-type'] = 'application/json';
141
+ this.reply(responseString);
142
+ }
143
+
144
+ public reply(context: string | Buffer): void {
145
+ this.logger_.logMethodArgs?.('reply', this.clientRequest.url.debugId);
146
+
147
+ if (this.raw_.writableFinished && this.hasBeenSent_ === false) {
148
+ // The response has already been sent by direct access to the server api.
149
+ this.logger_.accident('reply', 'server_response_writable_finished_directly');
150
+ this.hasBeenSent_ = true;
151
+ }
152
+
153
+ if (this.hasBeenSent_) {
154
+ this.logger_.accident('reply', 'reply_already_sent', {
155
+ url: this.clientRequest.url.debugId,
156
+ replySent: this.hasBeenSent_,
157
+ writableFinished: this.raw_.writableFinished,
158
+ });
159
+ return;
160
+ }
161
+
162
+ this.hasBeenSent_ = true;
163
+
164
+ try {
165
+ if (typeof context === 'string') {
166
+ context = Buffer.from(context);
167
+ }
168
+
169
+ this.headers['content-length'] = context.byteLength;
170
+
171
+ this.applyHeaders_();
172
+ this.raw_.end(context, 'binary');
173
+ }
174
+ catch (error) {
175
+ this.logger_.error('reply', 'server_response_error', error, this.clientRequest.url.debugId);
176
+ this.hasBeenSent_ = false;
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,337 @@
1
+ import {createServer} from 'node:http';
2
+
3
+ import {createLogger} from '@alwatr/logger';
4
+
5
+ import {NanotronClientRequest} from './api-client-request.js';
6
+ import {HttpStatusCodes, HttpStatusMessages} from './const.js';
7
+ import {NanotronUrl} from './url.js';
8
+
9
+ import type {DefineRouteOption, MatchType, NativeClientRequest, NativeServerResponse} from './type.js';
10
+ import type {Duplex} from 'node:stream';
11
+
12
+ /**
13
+ * Configuration options for the NanotronApiServer.
14
+ */
15
+ export interface NanotronApiServerConfig {
16
+ /**
17
+ * The port number to listen on.
18
+ *
19
+ * @default 80
20
+ */
21
+ port?: number;
22
+
23
+ /**
24
+ * The hostname to listen on.
25
+ *
26
+ * @default '0.0.0.0'
27
+ */
28
+ host?: string;
29
+
30
+ /**
31
+ * Sets the timeout (ms) for receiving the entire request from the client.
32
+ *
33
+ * @default 10_000 ms
34
+ */
35
+ requestTimeout?: number;
36
+
37
+ /**
38
+ * Sets the timeout (ms) for receiving the complete HTTP headers from the client.
39
+ *
40
+ * This should be bigger than `keepAliveTimeout + your server's expected response time`.
41
+ *
42
+ * @default 130_000 ms
43
+ */
44
+ headersTimeout?: number;
45
+
46
+ /**
47
+ * Sets the timeout (ms) for receiving the complete HTTP headers from the client.
48
+ *
49
+ * @default 120_000 ms
50
+ */
51
+ keepAliveTimeout?: number;
52
+
53
+ /**
54
+ * Add /health route.
55
+ *
56
+ * @default true
57
+ */
58
+ healthRoute?: boolean;
59
+
60
+ /**
61
+ * Add OPTIONS route for preflight requests to allow access origins.
62
+ *
63
+ * @default {enable: false, origin: '*', methods: '*', headers: '*', maxAge: 86_400}
64
+ */
65
+ crossOrigin?: {
66
+ enable: boolean;
67
+ origin: string;
68
+ methods: string;
69
+ headers: string;
70
+ maxAge: string | number;
71
+ };
72
+
73
+ /**
74
+ * A prefix to be added to the beginning of the `url` of all defined routes.
75
+ *
76
+ * @default '/api/'
77
+ */
78
+ prefix?: `/${string}/` | '/';
79
+
80
+ /**
81
+ * The maximum size of the request body in bytes.
82
+ *
83
+ * @default `1_048_576` (1MiB)
84
+ */
85
+ bodyLimit?: number;
86
+ }
87
+
88
+ export class NanotronApiServer {
89
+ protected static readonly defaultConfig_: Readonly<Required<NanotronApiServerConfig>> = {
90
+ host: '0.0.0.0',
91
+ port: 80,
92
+ requestTimeout: 10_000,
93
+ headersTimeout: 130_000,
94
+ keepAliveTimeout: 120_000,
95
+ healthRoute: true,
96
+ crossOrigin: {
97
+ enable: false,
98
+ origin: '*',
99
+ methods: '*',
100
+ headers: '*',
101
+ maxAge: 86_400, // 24h
102
+ },
103
+ prefix: '/api/',
104
+ bodyLimit: 1_048_576, // 1MiB
105
+ };
106
+
107
+ public readonly config_: Required<NanotronApiServerConfig>;
108
+ protected readonly logger_;
109
+
110
+ public readonly httpServer;
111
+
112
+ protected readonly routeHandlerList__: Record<MatchType, DictionaryOpt<DictionaryOpt<Required<DefineRouteOption>>>>;
113
+
114
+ constructor(config?: Partial<NanotronApiServerConfig>) {
115
+ // Merge the config with the default config.
116
+ this.config_ = {
117
+ ...NanotronApiServer.defaultConfig_,
118
+ ...config,
119
+ };
120
+
121
+ // Create logger.
122
+ this.logger_ = createLogger('nt-api-server' + (this.config_.port !== 80 ? ':' + this.config_.port : ''));
123
+ this.logger_.logMethodArgs?.('new', {config: this.config_});
124
+
125
+ // Bind methods.
126
+ this.handleClientRequest_ = this.handleClientRequest_.bind(this);
127
+ this.handleServerError_ = this.handleServerError_.bind(this);
128
+ this.handleClientError_ = this.handleClientError_.bind(this);
129
+
130
+ // Initialize route handler list.
131
+ this.routeHandlerList__ = {
132
+ exact: {},
133
+ startsWith: {},
134
+ };
135
+
136
+ // Create the HTTP server.
137
+ this.httpServer = createServer(
138
+ {
139
+ keepAlive: true,
140
+ keepAliveInitialDelay: 0,
141
+ noDelay: true,
142
+ },
143
+ this.handleClientRequest_,
144
+ );
145
+
146
+ // Configure the server.
147
+ this.httpServer.requestTimeout = this.config_.requestTimeout;
148
+ this.httpServer.keepAliveTimeout = this.config_.keepAliveTimeout;
149
+ this.httpServer.headersTimeout = this.config_.headersTimeout;
150
+
151
+ // Start the server.
152
+ this.httpServer.listen(this.config_.port, this.config_.host, () => {
153
+ this.logger_.logOther?.(`listening on ${this.config_.host}:${this.config_.port}`);
154
+ });
155
+
156
+ // Handle server errors.
157
+ this.httpServer.on('error', this.handleServerError_);
158
+ this.httpServer.on('clientError', this.handleClientError_);
159
+
160
+ this.defineCorsRoute_();
161
+
162
+ if (this.config_.healthRoute) {
163
+ this.defineHealthRoute_();
164
+ }
165
+ }
166
+
167
+ public close(): void {
168
+ this.logger_.logMethod?.('close');
169
+ this.httpServer.close();
170
+ }
171
+
172
+ protected getRouteOption_(url: NanotronUrl): Required<DefineRouteOption> | null {
173
+ this.logger_.logMethod?.('getRouteOption_');
174
+
175
+ if (
176
+ Object.hasOwn(this.routeHandlerList__.exact, url.method) &&
177
+ Object.hasOwn(this.routeHandlerList__.exact[url.method]!, url.pathname)
178
+ ) {
179
+ return this.routeHandlerList__.exact[url.method]![url.pathname]!;
180
+ }
181
+
182
+ if (Object.hasOwn(this.routeHandlerList__.startsWith, url.method)) {
183
+ const routeList = this.routeHandlerList__.startsWith[url.method];
184
+ for (const pathname in routeList) {
185
+ if (url.pathname.indexOf(pathname) === 0) {
186
+ return routeList[pathname]!;
187
+ }
188
+ }
189
+ }
190
+
191
+ this.logger_.incident?.('getRouteOption_', 'route_not_found', {method: url.method, url: url.pathname});
192
+ return null;
193
+ }
194
+
195
+ protected setRouteOption_(option: Required<DefineRouteOption>): void {
196
+ this.logger_.logMethodArgs?.('setRouteOption_', option);
197
+
198
+ const routeHandlerList = this.routeHandlerList__[option.matchType];
199
+
200
+ routeHandlerList[option.method] ??= {};
201
+
202
+ if (Object.hasOwn(routeHandlerList[option.method]!, option.url)) {
203
+ this.logger_.error('defineRoute', 'route_already_exists', option);
204
+ throw new Error('route_already_exists');
205
+ }
206
+
207
+ routeHandlerList[option.method]![option.url] = option;
208
+ }
209
+
210
+ public defineRoute<TSharedMeta extends DictionaryOpt = DictionaryOpt>(option: DefineRouteOption<TSharedMeta>): void {
211
+ const option_: Required<DefineRouteOption<TSharedMeta>> = {
212
+ matchType: 'exact',
213
+ preHandlers: [],
214
+ postHandlers: [],
215
+ bodyLimit: this.config_.bodyLimit,
216
+ crossOrigin: this.config_.crossOrigin,
217
+ ...option,
218
+ };
219
+ this.logger_.logMethodArgs?.('defineRoute', option_);
220
+ this.setRouteOption_(option_ as Required<DefineRouteOption>);
221
+ }
222
+
223
+ protected handleServerError_(error: NodeJS.ErrnoException): void {
224
+ if (error.code === 'EADDRINUSE') {
225
+ this.logger_.error('handleServerError_', 'address_in_use', error);
226
+ }
227
+ else {
228
+ this.logger_.error('handleServerError_', 'http_server_error', error);
229
+ }
230
+ }
231
+
232
+ protected handleClientError_(err: NodeJS.ErrnoException, socket: Duplex): void {
233
+ this.logger_.accident('handleClientError_', 'http_server_client_error', {
234
+ errCode: err.code,
235
+ errMessage: err.message,
236
+ });
237
+
238
+ const errorCode = err.code?.toLowerCase() ?? `error_${HttpStatusCodes.Error_Client_400_Bad_Request}`;
239
+ const errorMessage = err.message ?? HttpStatusMessages[HttpStatusCodes.Error_Client_400_Bad_Request];
240
+ const errorResponse = `{"ok":false,"errorCode":"${errorCode}","errorMessage":"${errorMessage}"}`;
241
+
242
+ const responseHeaders = [
243
+ `HTTP/1.1 ${HttpStatusCodes.Error_Client_400_Bad_Request} ${HttpStatusMessages[HttpStatusCodes.Error_Client_400_Bad_Request]}`,
244
+ 'content-type: application/json',
245
+ `content-length: ${Buffer.byteLength(errorResponse)}`,
246
+ '\r\n',
247
+ ].join('\r\n');
248
+
249
+ socket.end(responseHeaders + errorResponse);
250
+ }
251
+
252
+ protected async handleClientRequest_(
253
+ nativeClientRequest: NativeClientRequest,
254
+ nativeServerResponse: NativeServerResponse,
255
+ ): Promise<void> {
256
+ this.logger_.logMethod?.('handleClientRequest_');
257
+
258
+ if (nativeClientRequest.url === undefined) {
259
+ this.logger_.accident('handleClientRequest_', 'http_server_url_undefined');
260
+ return;
261
+ }
262
+
263
+ const url = new NanotronUrl(nativeClientRequest, this.config_.prefix);
264
+
265
+ const routeOption = this.getRouteOption_(url);
266
+
267
+ const connection = new NanotronClientRequest(url, nativeClientRequest, nativeServerResponse, routeOption);
268
+
269
+ if (routeOption === null) {
270
+ connection.serverResponse.statusCode = HttpStatusCodes.Error_Client_404_Not_Found;
271
+ connection.serverResponse.replyError();
272
+ return;
273
+ }
274
+
275
+ try {
276
+ for (const handler of routeOption.preHandlers) {
277
+ await handler.call(connection, connection, connection.serverResponse, connection.sharedMeta);
278
+ if (connection.terminatedHandlers === true) return; // must check after each pre-handler.
279
+ }
280
+
281
+ await routeOption.handler.call(connection, connection, connection.serverResponse, connection.sharedMeta);
282
+
283
+ for (const handler of routeOption.postHandlers) {
284
+ if (connection.terminatedHandlers === true) return; // must check before each post-handler.
285
+ await handler.call(connection, connection, connection.serverResponse, connection.sharedMeta);
286
+ }
287
+ }
288
+ catch (error) {
289
+ this.logger_.error('handleClientRequest_', 'route_handler_error', error, url.debugId);
290
+
291
+ if (connection.serverResponse.statusCode < HttpStatusCodes.Error_Client_400_Bad_Request) {
292
+ connection.serverResponse.statusCode = HttpStatusCodes.Error_Server_500_Internal_Server_Error;
293
+ }
294
+ connection.serverResponse.replyError(error);
295
+ }
296
+
297
+ // TODO: handled open remained connections.
298
+ }
299
+
300
+ protected defineHealthRoute_(): void {
301
+ this.logger_.logMethod?.('defineHealthRoute_');
302
+
303
+ this.defineRoute({
304
+ method: 'GET',
305
+ url: '/health',
306
+ handler: function () {
307
+ const res = this.serverResponse.raw_;
308
+ res.statusCode = HttpStatusCodes.Success_200_OK;
309
+ res.setHeader('server', 'Alwatr Nanotron');
310
+ res.setHeader('content-type', 'application/json');
311
+ res.end('{"ok":true}');
312
+ },
313
+ });
314
+ }
315
+
316
+ protected defineCorsRoute_(): void {
317
+ this.logger_.logMethod?.('defineCorsRoute_');
318
+ const crossOrigin = this.config_.crossOrigin;
319
+ if (crossOrigin?.enable !== true) return;
320
+ this.defineRoute({
321
+ method: 'OPTIONS',
322
+ matchType: 'startsWith',
323
+ url: '/',
324
+ handler: function () {
325
+ const res = this.serverResponse.raw_;
326
+ res.writeHead(HttpStatusCodes.Success_204_No_Content, {
327
+ 'access-control-allow-origin': crossOrigin.origin,
328
+ 'access-control-allow-methods': crossOrigin.methods,
329
+ 'access-control-allow-headers': crossOrigin.headers,
330
+ 'access-control-max-age': crossOrigin.maxAge + '',
331
+ 'content-length': 0,
332
+ });
333
+ res.end();
334
+ },
335
+ });
336
+ }
337
+ }