UNPKG

@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
"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);