UNPKG

nestjs-aborter

Version:

Automatic request cancellation and timeout handling for NestJS applications

142 lines (141 loc) 6.55 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var AborterInterceptor_1; Object.defineProperty(exports, "__esModule", { value: true }); exports.AborterInterceptor = exports.ABORT_CONTROLLER_OPTIONS = exports.ABORT_CONTROLLER_KEY = void 0; const common_1 = require("@nestjs/common"); const rxjs_1 = require("rxjs"); const operators_1 = require("rxjs/operators"); const core_1 = require("@nestjs/core"); const timeout_decorator_1 = require("../decorators/timeout.decorator"); exports.ABORT_CONTROLLER_KEY = 'abortController'; exports.ABORT_CONTROLLER_OPTIONS = 'ABORT_CONTROLLER_OPTIONS'; /** * Interceptor that automatically attaches an AbortController to every HTTP request. * * This enables automatic cancellation of ongoing operations when: * - The client disconnects * - A configured timeout is reached * - An error occurs during request processing */ let AborterInterceptor = AborterInterceptor_1 = class AborterInterceptor { reflector; options; logger = new common_1.Logger(AborterInterceptor_1.name); constructor(reflector, options = {}) { this.reflector = reflector; this.options = options; } intercept(context, next) { const http = context.switchToHttp(); const request = http.getRequest(); if (this.shouldSkipRoute(request)) { return next.handle(); } const abortController = this.attachAbortController(request); const effectiveTimeout = this.getEffectiveTimeout(context); request.effectiveTimeout = effectiveTimeout ?? undefined; if (effectiveTimeout) { abortController.signal._requestTimeout = effectiveTimeout; } const cleanup = this.setupRequestCleanup(request, abortController); return (0, rxjs_1.race)(next.handle(), this.createAbortObservable(request, abortController), this.createTimeoutObservable(request, abortController, effectiveTimeout)).pipe((0, operators_1.catchError)((error) => { cleanup(`Handler error: ${error.message}`); return (0, rxjs_1.throwError)(() => error); }), (0, operators_1.finalize)(cleanup)); } getEffectiveTimeout(context) { const routeTimeout = this.reflector.get(timeout_decorator_1.REQUEST_TIMEOUT_KEY, context.getHandler()); // Route timeout takes precedence (even if 0/null) if (routeTimeout !== undefined) { return routeTimeout; } // Fall back to global timeout return this.options.timeout ?? null; } attachAbortController(request) { const abortController = new AbortController(); request.abortController = abortController; return abortController; } setupRequestCleanup(request, abortController) { const abort = (reason) => { if (abortController.signal.aborted) return; const abortReason = reason || this.options.reason || 'Request terminated'; request.abortReason = abortReason; abortController.abort(abortReason); this.logAbort('cleanup', request, abortReason); }; const closeHandler = () => abort('Client disconnected'); const errorHandler = (error) => abort(`Request error: ${error.message}`); request.on('close', closeHandler); request.on('error', errorHandler); return (reason) => { abort(reason || ''); request.off('close', closeHandler); request.off('error', errorHandler); }; } createAbortObservable(request, abortController) { return new rxjs_1.Observable((subscriber) => { const handler = () => { const reason = request.abortReason || 'Request aborted'; subscriber.error(new common_1.RequestTimeoutException(reason)); }; abortController.signal.addEventListener('abort', handler); return () => abortController.signal.removeEventListener('abort', handler); }); } createTimeoutObservable(request, abortController, effectiveTimeout) { if (!effectiveTimeout || effectiveTimeout <= 0) { return new rxjs_1.Observable(); } return (0, rxjs_1.timer)(effectiveTimeout).pipe((0, operators_1.mergeMap)(() => { const reason = `Request timed out after ${effectiveTimeout}ms`; request.abortReason = reason; abortController.abort(reason); this.logAbort('timeout', request, reason); return (0, rxjs_1.throwError)(() => new common_1.RequestTimeoutException(reason)); })); } shouldSkipRoute(request) { const { skipRoutes = [], skipMethods = [] } = this.options; if (skipRoutes.some((route) => request.path.match(new RegExp(route)))) { return true; } if (skipMethods.includes(request.method.toUpperCase())) { return true; } return false; } logAbort(type, request, reason) { if (!this.options.enableLogging) return; const message = `[${type.toUpperCase()}] ${request.method} ${request.path} - ${reason}`; if (this.options.logger) { this.options.logger(message); } else { this.logger.warn(message); } } }; exports.AborterInterceptor = AborterInterceptor; exports.AborterInterceptor = AborterInterceptor = AborterInterceptor_1 = __decorate([ (0, common_1.Injectable)(), __param(1, (0, common_1.Optional)()), __param(1, (0, common_1.Inject)(exports.ABORT_CONTROLLER_OPTIONS)), __metadata("design:paramtypes", [core_1.Reflector, Object]) ], AborterInterceptor);