nestjs-aborter
Version:
Automatic request cancellation and timeout handling for NestJS applications
142 lines (141 loc) • 6.55 kB
JavaScript
;
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);