UNPKG

@litert/televoke

Version:
341 lines (256 loc) 10 kB
/** * Copyright 2025 Angus.Fenying <fenying@litert.org> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type * as Listener from '../http-listener'; import type * as Http from 'node:http'; import type * as dT from '../Transporter.decl'; import * as Shared from '../../shared'; import { EventEmitter } from 'node:events'; import { LegacyHttpTransporter } from './LegacyHttp.Transporter'; import * as v1 from '../../shared/Encodings/v1'; const encoder = new v1.TvEncoderV1(); const INVALID_REQUEST_RESPONSE = Buffer.from(encoder.encodeApiErrorResponse( 'null', Shared.Encodings.v1.EResponseCode.MALFORMED_ARGUMENTS, '"INVALID REQUEST"', 0 )); function refuseBadRequest(resp: Http.ServerResponse): void { try { resp.writeHead(400, { 'content-length': INVALID_REQUEST_RESPONSE.byteLength, }); const socket = resp.socket!; resp.end(INVALID_REQUEST_RESPONSE); socket.destroy(); } catch { // do nothing. } } export interface IHttpGatewayOptions { hostname?: string; port?: number; backlog?: number; } export interface IRegisterListenerOptions { onErrorCallback: (e: Error) => void; onRequestCallback: (req: Http.IncomingMessage, resp: Http.ServerResponse) => void; } export interface IRegisterListenerResult { stop?: () => void | Promise<void>; start?: () => void | Promise<void>; readonly running: boolean; } class LegacyHttpGateway extends EventEmitter implements dT.IGateway { private readonly _listener: IRegisterListenerResult; public constructor( registerListener: (opts: IRegisterListenerOptions) => IRegisterListenerResult, private readonly _server: dT.IServer, ) { super(); if (this._server.router.encoding !== 'json') { throw new TypeError('Legacy HTTP gateway only supports JSON encoding'); } this._listener = registerListener({ onErrorCallback: (e) => this.emit('error', e), onRequestCallback: this._onRequest, }); } private _sendResponse(resp: Http.ServerResponse, data: string): void { try { resp.setHeader('content-length', Buffer.byteLength(data)); resp.end(data); } catch (e) { this.emit('error', e); } } public get running(): boolean { return this._listener.running; } private readonly _onRequest = (req: Http.IncomingMessage, resp: Http.ServerResponse): void => { if (req.method !== 'POST' || !req.headers['content-length']) { refuseBadRequest(resp); this.emit('error', new Shared.errors.invalid_packet({ reason: 'invalid_request', data: { 'method': req.method, 'headers': req.headers, 'url': req.url, }, })); return; } const length = parseInt(req.headers['content-length']); if (!Number.isSafeInteger(length) || length > v1.MAX_PACKET_SIZE) { // Maximum request packet is 64MB refuseBadRequest(resp); this.emit('error', new Shared.errors.invalid_packet({ reason: 'invalid_packet_length', data: { 'length': length, 'max': v1.MAX_PACKET_SIZE, }, })); return; } const buf: Buffer = Buffer.allocUnsafe(length); let offset: number = 0; const recvAt = Date.now(); resp.on('error', (e) => this.emit('error', e)); req.on('error', (e) => this.emit('error', e)) .on('data', (chunk: Buffer) => { const index = offset; offset += chunk.byteLength; if (offset > length) { refuseBadRequest(resp); this.emit('error', new Shared.errors.invalid_packet({ reason: 'length_exceeded', recv: offset, expected: length, })); return; } chunk.copy(buf, index); }) .on('end', () => { let input: any; if (offset !== length) { refuseBadRequest(resp); return; } try { input = JSON.parse(buf as any); } catch (e) { refuseBadRequest(resp); this.emit('error', new Shared.errors.invalid_packet({ reason: 'invalid_json', }, e)); return; } if (typeof input?.api !== 'string') { refuseBadRequest(resp); this.emit('error', new Shared.errors.invalid_packet({ reason: 'malformed_json', })); return; } this._server.processLegacyApi((result) => { if (!resp.writable) { return; } if (result instanceof Shared.TelevokeError) { if (result instanceof Shared.errors.app_error) { this._sendResponse(resp, encoder.encodeApiErrorResponse( input.rid, v1.EResponseCode.FAILURE, result.message, recvAt )); } else if (result instanceof Shared.errors.api_not_found) { this._sendResponse(resp, encoder.encodeApiErrorResponse( input.rid, v1.EResponseCode.API_NOT_FOUND, 'null', recvAt )); } else if (result instanceof Shared.ProtocolError) { this._sendResponse(resp, encoder.encodeApiErrorResponse( input.rid, v1.EResponseCode.SYSTEM_ERROR, JSON.stringify({ name: result.name, message: result.message, data: result.data, }), recvAt )); } else { this._sendResponse(resp, encoder.encodeApiErrorResponse( input.rid, v1.EResponseCode.SYSTEM_ERROR, 'null', recvAt )); } return; } let data: string; try { data = encoder.encodeApiOkResponse(input.rid, result ?? null, recvAt); } catch (e) { data = encoder.encodeApiErrorResponse( input.rid, v1.EResponseCode.SYSTEM_ERROR, 'null', recvAt ); this.emit('error', new Shared.errors.unprocessable_error({ api: input.api }, e)); } this._sendResponse(resp, data); }, input.api, input.args, new LegacyHttpTransporter(req)); }); }; public async start(): Promise<void> { if (this.running) { return; } await this._listener.start?.(); } public async stop(): Promise<void> { if (!this.running) { return Promise.resolve(); } await this._listener.stop?.(); } } /** * Create a legacy HTTP gateway, binding to a built-in simple HTTP server. * * > When using built-in HTTP server, the api will ignore headers, path and query string in the URL. * * @param listener The built-in HTTP listener to bind to. * @param server The server to process the requests. */ export function createLegacyHttpGateway( listener: Listener.IHttpListener, server: dT.IServer ): dT.IGateway { return new LegacyHttpGateway((o) => { listener.on('error', o.onErrorCallback); listener.setLegacyApiProcessor(o.onRequestCallback); return listener; }, server); } /** * Create a legacy HTTP gateway, binding to a custom HTTP server. * * > When using a custom HTTP server, it's able to preprocess the request before passing to the server, like * > authentication, rate limiting, etc. * * @param registerListener The function to register the listener to the custom HTTP server. * @param server The server to process the requests. */ export function createCustomLegacyHttpGateway( registerListener: (opts: IRegisterListenerOptions) => IRegisterListenerResult, server: dT.IServer ): dT.IGateway { return new LegacyHttpGateway(registerListener, server); }