@danielbiegler/vendure-plugin-blurry-image-lazy-loading
Version:
Generates image hashes for displaying blurry previews when loading images on the frontend.
407 lines (406 loc) • 20.1 kB
JavaScript
"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 __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PreviewImageHashService = void 0;
const common_1 = require("@nestjs/common");
const core_1 = require("@vendure/core");
const sharp_1 = __importDefault(require("sharp"));
const typeorm_1 = require("typeorm");
const util_1 = require("util");
const constants_1 = require("../constants");
const generated_admin_types_1 = require("../generated-admin-types");
/**
* The PreviewImageHashService provides methods for managing the creation of image hashes.
*
* @category Services
*/
let PreviewImageHashService = class PreviewImageHashService {
assetService;
collectionService;
productService;
productVariantService;
configService;
eventBus;
jobQueueService;
connection;
options;
/** @internal */
constructor(assetService, collectionService, productService, productVariantService, configService, eventBus, jobQueueService, connection, options) {
this.assetService = assetService;
this.collectionService = collectionService;
this.productService = productService;
this.productVariantService = productVariantService;
this.configService = configService;
this.eventBus = eventBus;
this.jobQueueService = jobQueueService;
this.connection = connection;
this.options = options;
}
jobQueue;
/**
* Adds the hashing to the dedicated job queue
*/
async addToJobQueue(ctx, input) {
const job = await this.jobQueue.add({
ctx: ctx.serialize(),
input,
});
core_1.Logger.verbose(`Image hashing job "${job.id}" added to queue "${job.queueName}"`, constants_1.loggerCtx);
return job;
}
/**
* Convenience method to make the processing code more concise
*/
result(jobsAddedToQueue, assetsSkipped, code, message) {
return {
__typename: "PluginPreviewImageHashResult",
jobsAddedToQueue,
assetsSkipped,
code,
message,
};
}
/**
* Bootstrapping the plugin
*/
async onModuleInit() {
const shouldSubscribe = this.options.enqueueHashingAfterAssetCreation ?? constants_1.DEFAULT_ENQUEUE_HASHING_AFTER_ASSET_CREATION;
if (shouldSubscribe) {
this.eventBus.ofType(core_1.AssetEvent).subscribe(async (event) => {
if (event.type === "created") {
await this.addToJobQueue(event.ctx, {
idAsset: event.entity.id,
});
}
});
core_1.Logger.info("Subscribed to the asset creation event", constants_1.loggerCtx);
}
else {
core_1.Logger.info("Skipped subscribing to the asset creation event due to a false `enqueueHashingAfterAssetCreation`", constants_1.loggerCtx);
}
this.jobQueue = await this.jobQueueService.createQueue({
name: "plugin-preview-image-hash",
process: async (job) => {
const input = {
...job.data.input,
runSynchronously: true,
};
return await this.create(core_1.RequestContext.deserialize(job.data.ctx), input);
},
});
}
/**
* Depending on `input.runSynchronously` will either generate the hash and persist it to the asset,
* or will add this task to the dedicated job queue.
*
* If being run synchronously will return the asset so you can query the custom field directly
*/
async create(ctx, input, relations) {
// Early exit if the job queue should take over
if (!input.runSynchronously) {
await this.addToJobQueue(ctx, input);
return this.result(1, 0, generated_admin_types_1.PluginPreviewImageHashResultCode.OK, "Successfully added task to job queue");
}
const asset = await this.assetService.findOne(ctx, input.idAsset);
if (!asset) {
const errorMsg = `Failed to find asset with ID: "${input.idAsset}"`;
core_1.Logger.error(errorMsg, constants_1.loggerCtx);
return this.result(0, 0, generated_admin_types_1.PluginPreviewImageHashResultCode.ENTITY_NOT_FOUND, errorMsg);
}
let mimetype = null;
try {
mimetype = new util_1.MIMEType(asset.mimeType);
if (mimetype.type !== "image") {
const errorMsg = `Asset is not of type "image", found type: "${mimetype.type}"`;
core_1.Logger.error(errorMsg, constants_1.loggerCtx);
return this.result(0, 0, generated_admin_types_1.PluginPreviewImageHashResultCode.WRONG_MIMETYPE, errorMsg);
}
if (constants_1.SUPPORTED_IMG_TYPES.includes(mimetype.subtype) === false) {
const errorMsg = `Image is not any of subtype [${constants_1.SUPPORTED_IMG_TYPES}], found subtype: "${mimetype.subtype}"`;
core_1.Logger.error(errorMsg, constants_1.loggerCtx);
return this.result(0, 0, generated_admin_types_1.PluginPreviewImageHashResultCode.WRONG_MIMETYPE, errorMsg);
}
}
catch (error) {
const errorMsg = "Failed to parse mimetype";
core_1.Logger.error(errorMsg, constants_1.loggerCtx, error instanceof Error ? error.stack : undefined);
return this.result(0, 0, generated_admin_types_1.PluginPreviewImageHashResultCode.WRONG_MIMETYPE, errorMsg);
}
let bufferInput = null;
try {
bufferInput = await this.configService.assetOptions.assetStorageStrategy.readFileToBuffer(asset.preview);
}
catch (error) {
const errorMsg = `Failed to fetch image from URL: "${asset.preview}"`;
core_1.Logger.error(errorMsg, constants_1.loggerCtx, error instanceof Error ? error.stack : undefined);
return this.result(0, 0, generated_admin_types_1.PluginPreviewImageHashResultCode.FAIL_FETCH, errorMsg);
}
if (bufferInput.length === 0) {
const errorMsg = `Image buffer is empty. Aborting now.`;
core_1.Logger.error(errorMsg, constants_1.loggerCtx);
return this.result(0, 0, generated_admin_types_1.PluginPreviewImageHashResultCode.FAIL_EMPTY_BUFFER, errorMsg);
}
let bufferOutput = null;
try {
bufferOutput = (0, sharp_1.default)(bufferInput);
}
catch (error) {
const errorMsg = "Failed to open image via sharp";
core_1.Logger.error(errorMsg, constants_1.loggerCtx, error instanceof Error ? error.stack : undefined);
return this.result(0, 0, generated_admin_types_1.PluginPreviewImageHashResultCode.FAIL_ENCODE, errorMsg);
}
let hash = null;
try {
hash = await this.options.hashingStrategy.encode(bufferOutput, input);
}
catch (error) {
const errorMsg = "Failed to encode image";
core_1.Logger.error(errorMsg, constants_1.loggerCtx, error instanceof Error ? error.stack : undefined);
return this.result(0, 0, generated_admin_types_1.PluginPreviewImageHashResultCode.FAIL_ENCODE, errorMsg);
}
try {
await this.assetService.update(ctx, {
id: asset.id,
customFields: { previewImageHash: hash },
});
core_1.Logger.info(`Generated preview image hash for asset: "${asset.id}"`, constants_1.loggerCtx);
// @ts-expect-error Because we extend the CustomAssetField type (see types.ts) which doesnt quite match the gql type
return (0, core_1.assertFound)(this.assetService.findOne(ctx, input.idAsset, relations));
}
catch (error) {
const errorMsg = "Failed to update asset entity";
core_1.Logger.error(errorMsg, constants_1.loggerCtx, error instanceof Error ? error.stack : undefined);
return this.result(0, 0, generated_admin_types_1.PluginPreviewImageHashResultCode.FAIL_SAVE_ASSET, errorMsg);
}
}
/**
* Enqueues hashing jobs for each asset inside this product.
* Includes the product itself and all of its variants.
*
* Deduplicates the asset IDs.
*/
async createForProduct(ctx, input) {
let product = null;
let jobsAddedToQueue = 0;
let assetsSkipped = 0;
try {
product = await this.productService.findOne(ctx, input.idProduct, ["assets", "variants.assets"]);
}
catch (error) {
const errorMsg = "Something unexpected happened when fetching the product";
core_1.Logger.error(errorMsg, constants_1.loggerCtx, error instanceof Error ? error.stack : undefined);
return this.result(jobsAddedToQueue, assetsSkipped, generated_admin_types_1.PluginPreviewImageHashResultCode.UNEXPECTED_ERROR, errorMsg);
}
if (!product) {
const errorMsg = `Failed to find product with ID: "${input.idProduct}"`;
core_1.Logger.error(errorMsg, constants_1.loggerCtx);
return this.result(jobsAddedToQueue, assetsSkipped, generated_admin_types_1.PluginPreviewImageHashResultCode.ENTITY_NOT_FOUND, errorMsg);
}
const assetIds = new Set();
for (const asset of product.assets) {
assetIds.add(asset.assetId);
}
for (const variant of product.variants) {
for (const asset of variant.assets) {
assetIds.add(asset.assetId);
}
}
for (const idAsset of assetIds) {
await this.addToJobQueue(ctx, { idAsset });
jobsAddedToQueue += 1;
}
return this.result(jobsAddedToQueue, assetsSkipped, generated_admin_types_1.PluginPreviewImageHashResultCode.OK, "Successfully added all eligible hashing-tasks of product-/ and its variant-assets to job queue");
}
/**
* Helper for determining whether or not we should skip hash generation for a given asset.
* Useful for keeping the logging DRY.
*
* @param shouldRegenerate Whether or not you want to regenerate existing hashes
* @param asset The asset for which you would like to check
* @returns Whether or not you should skip this asset
*/
shouldSkipGeneratingHash(shouldRegenerate, asset) {
if (asset.customFields.previewImageHash && !shouldRegenerate) {
core_1.Logger.verbose(`Skipped generating hash for asset "${asset.id}", because regeneration of existing hashes is disabled`, constants_1.loggerCtx);
return true;
}
else {
return false;
}
}
/**
* Enqueues hashing jobs for each asset inside a collection. This includes the collection itself,
* the contained products and all of their variants.
*
* Due to how large collections can become, you may want to disable the deduplication of asset ids.
*
* If deduplication is enabled, jobs will be created only after gathering all assets first.
* If disabled, jobs will be created as the assets are being read.
*
* No deduplication may result in assets being hashed multiple times, but the tradeoff is not having
* to hold potentially millions of records in memory and just letting the worker take care of them eventually.
*/
async createForCollection(ctx, input) {
let collection = null;
let jobsAddedToQueue = 0;
let assetsSkipped = 0;
try {
collection = await this.collectionService.findOne(ctx, input.idCollection, ["assets"]);
}
catch (error) {
const errorMsg = "Something unexpected happened when fetching the collection";
core_1.Logger.error(errorMsg, constants_1.loggerCtx, error instanceof Error ? error.stack : undefined);
return this.result(jobsAddedToQueue, assetsSkipped, generated_admin_types_1.PluginPreviewImageHashResultCode.UNEXPECTED_ERROR, errorMsg);
}
if (!collection) {
const errorMsg = `Failed to find collection with ID: "${input.idCollection}"`;
core_1.Logger.error(errorMsg, constants_1.loggerCtx);
return this.result(jobsAddedToQueue, assetsSkipped, generated_admin_types_1.PluginPreviewImageHashResultCode.ENTITY_NOT_FOUND, errorMsg);
}
const regenerateExistingHashes = input.regenerateExistingHashes ?? constants_1.DEFAULT_REGENERATE_HASHES;
const deduplicateAssetIds = input.deduplicateAssetIds ?? constants_1.DEFAULT_DEDUPLICATE_ASSET_IDS;
const assetIds = new Set();
for (const asset of collection.assets) {
if (this.shouldSkipGeneratingHash(regenerateExistingHashes, asset.asset)) {
assetsSkipped += 1;
continue;
}
if (deduplicateAssetIds) {
assetIds.add(asset.assetId);
}
else {
await this.addToJobQueue(ctx, { idAsset: asset.assetId });
jobsAddedToQueue += 1;
}
}
const take = input.batchSize && input.batchSize > 0 ? input.batchSize : constants_1.DEFAULT_COLLECTION_PAGINATION;
let skip = 0;
let hasMoreVariants = true;
do {
let variants = null;
try {
variants = await this.productVariantService.getVariantsByCollectionId(ctx, collection.id, { take, skip }, [
"assets",
"product.assets",
]);
}
catch (error) {
const errorMsg = "Something unexpected happened when querying product variants";
core_1.Logger.error(errorMsg, constants_1.loggerCtx, error instanceof Error ? error.stack : undefined);
return this.result(jobsAddedToQueue, assetsSkipped, generated_admin_types_1.PluginPreviewImageHashResultCode.UNEXPECTED_ERROR, errorMsg);
}
hasMoreVariants = variants.items.length > 0;
if (!hasMoreVariants)
break; // Early exit, as theres no work needed
skip += take;
for (const variant of variants.items) {
for (const asset of variant.assets) {
if (this.shouldSkipGeneratingHash(regenerateExistingHashes, asset.asset)) {
assetsSkipped += 1;
continue;
}
if (deduplicateAssetIds) {
assetIds.add(asset.assetId);
}
else {
await this.addToJobQueue(ctx, { idAsset: asset.assetId });
jobsAddedToQueue += 1;
}
}
for (const asset of variant.product.assets) {
if (this.shouldSkipGeneratingHash(regenerateExistingHashes, asset.asset)) {
assetsSkipped += 1;
continue;
}
if (deduplicateAssetIds) {
assetIds.add(asset.assetId);
}
else {
await this.addToJobQueue(ctx, { idAsset: asset.assetId });
jobsAddedToQueue += 1;
}
}
}
} while (hasMoreVariants);
if (deduplicateAssetIds) {
for (const id of assetIds) {
await this.addToJobQueue(ctx, { idAsset: id });
jobsAddedToQueue += 1;
}
}
return this.result(jobsAddedToQueue, assetsSkipped, generated_admin_types_1.PluginPreviewImageHashResultCode.OK, "Successfully added all eligible hashing-tasks of collection-/, product-/ and its variant-assets to job queue");
}
/**
* Create preview image hashes for all assets.
*
* This mutation should be handled with extra care since an installation may hold hundreds of thousands of images.
* This is mainly useful as a one-time-use utility to initialize all of the assets with hashes after installing the plugin.
*/
async createForAllAssets(ctx, input) {
let jobsAddedToQueue = 0;
const regenerateExistingHashes = input?.regenerateExistingHashes ?? constants_1.DEFAULT_REGENERATE_HASHES;
// Either all assets or the ones with no custom field
const optionsWhere = {
mimeType: (0, typeorm_1.In)(constants_1.SUPPORTED_MIME_TYPES),
customFields: { previewImageHash: regenerateExistingHashes ? undefined : (0, typeorm_1.IsNull)() }
};
const countTotal = await this.connection.getRepository(ctx, core_1.Asset).count();
const countNeeded = await this.connection.getRepository(ctx, core_1.Asset).count({ where: optionsWhere });
let assetsSkipped = countTotal - countNeeded;
const take = input?.batchSize && input.batchSize > 0 ? input.batchSize : constants_1.DEFAULT_COLLECTION_PAGINATION;
let skip = 0;
let hasMoreAssets = true;
do {
let assets = null;
try {
assets = await this.connection.getRepository(ctx, core_1.Asset).find({
select: { id: true, customFields: { previewImageHash: true } },
where: optionsWhere,
take,
skip,
loadEagerRelations: false,
});
// There arent more pages needed if the count is smaller than the `take`
hasMoreAssets = assets.length >= take;
}
catch (error) {
const errorMsg = "Something unexpected happened when querying assets";
core_1.Logger.error(errorMsg, constants_1.loggerCtx, error instanceof Error ? error.stack : undefined);
return this.result(jobsAddedToQueue, assetsSkipped, generated_admin_types_1.PluginPreviewImageHashResultCode.UNEXPECTED_ERROR, errorMsg);
}
skip += take;
for (const asset of assets) {
await this.addToJobQueue(ctx, { idAsset: asset.id });
jobsAddedToQueue += 1;
}
} while (hasMoreAssets);
return this.result(jobsAddedToQueue, assetsSkipped, generated_admin_types_1.PluginPreviewImageHashResultCode.OK, "Successfully added all eligible asset-hashing-tasks to job queue");
}
};
exports.PreviewImageHashService = PreviewImageHashService;
exports.PreviewImageHashService = PreviewImageHashService = __decorate([
(0, common_1.Injectable)(),
__param(8, (0, common_1.Inject)(constants_1.PLUGIN_INIT_OPTIONS)),
__metadata("design:paramtypes", [core_1.AssetService,
core_1.CollectionService,
core_1.ProductService,
core_1.ProductVariantService,
core_1.ConfigService,
core_1.EventBus,
core_1.JobQueueService,
core_1.TransactionalConnection, Object])
], PreviewImageHashService);