From 0c974a7d86a47fa8d02081a017be7fc48feaa6ed Mon Sep 17 00:00:00 2001 From: Ward Peeters Date: Tue, 15 Feb 2022 22:32:13 +0100 Subject: [PATCH] feat: add support for image-cdn --- package.json | 1 - packages/gatsby-plugin-manifest/package.json | 2 +- packages/gatsby-plugin-utils/.babelrc | 2 +- packages/gatsby-plugin-utils/README.md | 4 +- packages/gatsby-plugin-utils/package.json | 33 +- .../graphql/gatsby-image-data-resolver.ts | 514 ++++++++++++++++++ .../graphql/get-remote-file-enums.ts | 77 +++ .../graphql/public-url-resolver.ts | 38 ++ .../graphql/resize-resolver.ts | 110 ++++ .../src/polyfill-remote-file/graphql/utils.ts | 86 +++ .../src/polyfill-remote-file/http-routes.ts | 104 ++++ .../src/polyfill-remote-file/index.ts | 144 +++++ .../polyfill-remote-file/jobs/dispatchers.ts | 107 ++++ .../jobs/gatsby-worker.ts | 58 ++ .../placeholder-handler.ts | 262 +++++++++ .../polyfill-remote-file/transform-images.ts | 151 +++++ .../src/polyfill-remote-file/types.ts | 79 +++ .../src/polyfill-remote-file/utils/cache.ts | 6 + .../utils/get-gatsby-version.ts | 10 + .../utils/mime-type-helpers.ts | 14 + .../utils/strip-indent.ts | 14 + .../utils/url-generator.ts | 40 ++ packages/gatsby/index.d.ts | 2 +- packages/gatsby/package.json | 1 + packages/gatsby/scripts/__tests__/api.js | 4 +- packages/gatsby/scripts/output-api-file.js | 3 +- packages/gatsby/src/commands/serve.ts | 2 +- packages/gatsby/src/schema/schema-composer.ts | 4 + packages/gatsby/src/schema/schema.js | 12 +- .../types/__tests__/remote-file-interface.ts | 258 +++++++++ .../src/schema/types/remote-file-interface.ts | 47 ++ packages/gatsby/src/utils/start-server.ts | 27 +- yarn.lock | 150 ++--- 33 files changed, 2273 insertions(+), 93 deletions(-) create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/get-remote-file-enums.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/get-gatsby-version.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/mime-type-helpers.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/strip-indent.ts create mode 100644 packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts create mode 100644 packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts create mode 100644 packages/gatsby/src/schema/types/remote-file-interface.ts diff --git a/package.json b/package.json index 62e3ca73ac777..d074266e646b9 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "@types/bluebird": "^3.5.35", "@types/cache-manager": "^2.10.3", "@types/common-tags": "^1.8.0", - "@types/express": "^4.17.3", "@types/fs-extra": "^9.0.13", "@types/jaeger-client": "^3.18.0", "@types/jest": "^27.0.2", diff --git a/packages/gatsby-plugin-manifest/package.json b/packages/gatsby-plugin-manifest/package.json index f0fee66248e58..c6ca978e01420 100644 --- a/packages/gatsby-plugin-manifest/package.json +++ b/packages/gatsby-plugin-manifest/package.json @@ -11,7 +11,7 @@ "gatsby-core-utils": "^3.8.0-next.0", "gatsby-plugin-utils": "^3.2.0-next.0", "semver": "^7.3.5", - "sharp": "^0.29.3" + "sharp": "^0.30.0" }, "devDependencies": { "@babel/cli": "^7.15.4", diff --git a/packages/gatsby-plugin-utils/.babelrc b/packages/gatsby-plugin-utils/.babelrc index 3af9b5a3ea9ec..7d1e4eb1568a6 100644 --- a/packages/gatsby-plugin-utils/.babelrc +++ b/packages/gatsby-plugin-utils/.babelrc @@ -1,5 +1,5 @@ { - "presets": [["babel-preset-gatsby-package", { "browser": true }]], + "presets": [["babel-preset-gatsby-package"]], "overrides": [ { "test": ["**/*.ts"], diff --git a/packages/gatsby-plugin-utils/README.md b/packages/gatsby-plugin-utils/README.md index a1634b78e6ee8..601b488f54b51 100644 --- a/packages/gatsby-plugin-utils/README.md +++ b/packages/gatsby-plugin-utils/README.md @@ -76,7 +76,7 @@ Here's a list of features: ```js const { hasFeature } = require(`gatsby-plugin-utils`) -if (!hasFeature(`image-service`)) { - // You can polyfill image-service here so older versions have support as well +if (!hasFeature(`image-cdn`)) { + // You can polyfill image-cdn here so older versions have support as well } ``` diff --git a/packages/gatsby-plugin-utils/package.json b/packages/gatsby-plugin-utils/package.json index 5a63956d00cda..06d0b60c7a1d0 100644 --- a/packages/gatsby-plugin-utils/package.json +++ b/packages/gatsby-plugin-utils/package.json @@ -3,6 +3,30 @@ "version": "3.2.0-next.0", "description": "Gatsby utils that help creating plugins", "main": "dist/index.js", + "exports": { + ".": "./dist/index.js", + "./*.js": "./dist/*.js", + "./dist/*": "./dist/*.js", + "./dist/polyfill-remote-file": null, + "./dist/utils": null, + "./polyfill-remote-file": "./dist/polyfill-remote-file/index.js", + "./dist/polyfill-remote-file/jobs/gatsby-worker.js": "./dist/polyfill-remote-file/jobs/gatsby-worker.js" + }, + "typesVersions": { + "*": { + "*": [ + "dist/*.d.ts", + "dist/index.d.ts" + ], + "polyfill-remote-file": [ + "dist/polyfill-remote-file/index.d.ts" + ], + "dist/*": [ + "dist/*.d.ts", + "dist/index.d.ts" + ] + } + }, "scripts": { "build": "babel src --out-dir dist/ --ignore \"**/__tests__\" --extensions \".ts,.js\"", "watch": "babel -w src --out-dir dist/ --ignore \"**/__tests__\" --extensions \".ts,.js\"", @@ -22,7 +46,9 @@ "homepage": "https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-plugin-utils#readme", "dependencies": { "@babel/runtime": "^7.15.4", - "joi": "^17.4.2" + "gatsby-core-utils": "3.8.0-next.0", + "joi": "^17.4.2", + "mime": "^3.0.0" }, "devDependencies": { "@babel/cli": "^7.15.4", @@ -36,10 +62,9 @@ "gatsby": "^4.0.0-next" }, "files": [ - "dist/", - "src/" + "dist/" ], "engines": { "node": ">=14.15.0" } -} +} \ No newline at end of file diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts new file mode 100644 index 0000000000000..9cabaf59c24f9 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/gatsby-image-data-resolver.ts @@ -0,0 +1,514 @@ +import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" +import { getImageFormatFromMimeType } from "../utils/mime-type-helpers" +import { stripIndent } from "../utils/strip-indent" +import { + dispatchLocalImageServiceJob, + shouldDispatch, +} from "../jobs/dispatchers" +import { generatePlaceholder } from "../placeholder-handler" +import { isImage } from "../types" +import { validateAndNormalizeFormats, calculateImageDimensions } from "./utils" + +import type { Store } from "gatsby" +import type { PlaceholderType } from "../placeholder-handler" +import type { + IRemoteFileNode, + IRemoteImageNode, + IGraphQLFieldConfigDefinition, + ImageFormat, + ImageLayout, + CalculateImageSizesArgs, +} from "../types" +import type { getRemoteFileEnums } from "./get-remote-file-enums" + +interface IGatsbyImageData { + sources: Array<{ + srcSet: string + type: string + sizes: string + }> + fallback: { + srcSet: string + src: string + sizes: string + } +} + +interface ISourceMetadata { + width: number + height: number + format: ImageFormat + filename: string +} + +type IGatsbyImageDataArgs = CalculateImageSizesArgs & { + formats: Array + backgroundColor: string + placeholder: PlaceholderType | "none" + aspectRatio: number + sizes: string +} + +type ImageSizeArgs = CalculateImageSizesArgs & { + sourceMetadata: ISourceMetadata +} + +interface IImageSizes { + sizes: Array + presentationWidth: number + presentationHeight: number + aspectRatio: number + unscaledWidth: number +} + +const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] +const DEFAULT_BREAKPOINTS = [750, 1080, 1366, 1920] + +export async function gatsbyImageDataResolver( + source: IRemoteFileNode, + args: IGatsbyImageDataArgs, + store: Store +): Promise<{ + images: IGatsbyImageData + layout: string + width: number + height: number + backgroundColor: string + placeholder?: { fallback: string } | undefined +} | null> { + if (!isImage(source)) { + return null + } + + let backgroundColor = args.backgroundColor + const sourceMetadata: ISourceMetadata = { + width: source.width, + height: source.height, + format: getImageFormatFromMimeType(source.mimeType), + filename: source.filename, + } + const formats = validateAndNormalizeFormats( + args.formats, + sourceMetadata.format + ) + const imageSizes = calculateImageSizes(sourceMetadata, args) + const sizes = getSizesAttrFromLayout( + args.layout, + imageSizes.presentationWidth + ) + const result: Partial & { + sources: IGatsbyImageData["sources"] + } = { + sources: [], + fallback: undefined, + } + + for (const format of formats) { + let fallbackSrc: string | undefined = undefined + const images = imageSizes.sizes.map(width => { + if (shouldDispatch()) { + dispatchLocalImageServiceJob( + { + url: source.url, + extension: format, + width, + height: Math.round(width / imageSizes.aspectRatio), + format, + fit: args.fit, + contentDigest: source.internal.contentDigest, + }, + store + ) + } + + const src = `${generatePublicUrl(source)}/${generateImageArgs({ + width, + height: Math.round(width / imageSizes.aspectRatio), + format, + fit: args.fit, + })}.${format}` + + if (!fallbackSrc) { + fallbackSrc = src + } + + return { + src, + width, + } + }) + + if (format === sourceMetadata.format && fallbackSrc) { + result.fallback = { + src: fallbackSrc, + srcSet: createSrcSetFromImages(images), + sizes, + } + } else { + result.sources.push({ + srcSet: createSrcSetFromImages(images), + type: `image/${format}`, + sizes, + }) + } + } + + let placeholder: { fallback: string } | undefined + if (args.placeholder !== `none`) { + const { fallback, backgroundColor: bgColor } = await generatePlaceholder( + source, + args.placeholder as PlaceholderType + ) + + if (fallback) { + placeholder = { fallback } + } + if (bgColor) { + backgroundColor = bgColor + } + } + + return { + images: result as IGatsbyImageData, + layout: args.layout, + width: imageSizes.presentationWidth, + height: imageSizes.presentationHeight, + placeholder, + backgroundColor, + } +} + +export function generateGatsbyImageDataFieldConfig( + enums: ReturnType, + store: Store +): IGraphQLFieldConfigDefinition< + IRemoteFileNode | IRemoteImageNode, + ReturnType, + IGatsbyImageDataArgs +> { + return { + type: `JSON`, + args: { + layout: { + type: enums.layout.NonNull.getTypeName(), + description: stripIndent` + The layout for the image. + FIXED: A static image sized, that does not resize according to the screen width + FULL_WIDTH: The image resizes to fit its container. Pass a "sizes" option if it isn't going to be the full width of the screen. + CONSTRAINED: Resizes to fit its container, up to a maximum width, at which point it will remain fixed in size. + `, + }, + width: { + type: `Int`, + description: stripIndent` + The display width of the generated image for layout = FIXED, and the display width of the largest image for layout = CONSTRAINED. + The actual largest image resolution will be this value multiplied by the largest value in outputPixelDensities + Ignored if layout = FLUID. + `, + }, + height: { + type: `Int`, + description: stripIndent` + If set, the height of the generated image. If omitted, it is calculated from the supplied width, matching the aspect ratio of the source image.`, + }, + placeholder: { + type: enums.placeholder.getTypeName(), + defaultValue: enums.placeholder.getField(`DOMINANT_COLOR`).value, + description: stripIndent` + Format of generated placeholder image, displayed while the main image loads. + BLURRED: a blurred, low resolution image, encoded as a base64 data URI (default) + DOMINANT_COLOR: a solid color, calculated from the dominant color of the image. + TRACED_SVG: a low-resolution traced SVG of the image. + NONE: no placeholder. Set the argument "backgroundColor" to use a fixed background color.`, + }, + aspectRatio: { + type: `Float`, + description: stripIndent` + If set along with width or height, this will set the value of the other dimension to match the provided aspect ratio, cropping the image if needed. + If neither width or height is provided, height will be set based on the intrinsic width of the source image. + `, + }, + formats: { + type: enums.format.NonNull.List.getTypeName(), + description: stripIndent` + The image formats to generate. Valid values are AUTO (meaning the same format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], and you should rarely need to change this. Take care if you specify JPG or PNG when you do + not know the formats of the source images, as this could lead to unwanted results such as converting JPEGs to PNGs. Specifying + both PNG and JPG is not supported and will be ignored. + `, + defaultValue: [ + enums.format.getField(`AUTO`).value, + enums.format.getField(`WEBP`).value, + enums.format.getField(`AVIF`).value, + ], + }, + outputPixelDensities: { + type: `[Float]`, + defaultValue: DEFAULT_PIXEL_DENSITIES, + description: stripIndent` + A list of image pixel densities to generate for FIXED and CONSTRAINED images. You should rarely need to change this. It will never generate images larger than the source, and will always include a 1x image. + Default is [ 1, 2 ] for fixed images, meaning 1x, 2x, and [0.25, 0.5, 1, 2] for fluid. In this case, an image with a fluid layout and width = 400 would generate images at 100, 200, 400 and 800px wide. + `, + }, + breakpoints: { + type: `[Int]`, + defaultValue: DEFAULT_BREAKPOINTS, + description: stripIndent` + Specifies the image widths to generate. You should rarely need to change this. For FIXED and CONSTRAINED images it is better to allow these to be determined automatically, + based on the image size. For FULL_WIDTH images this can be used to override the default, which is [750, 1080, 1366, 1920]. + It will never generate any images larger than the source. + `, + }, + sizes: { + type: `String`, + description: stripIndent` + The "sizes" property, passed to the img tag. This describes the display size of the image. + This does not affect the generated images, but is used by the browser to decide which images to download. You can leave this blank for fixed images, or if the responsive image + container will be the full width of the screen. In these cases we will generate an appropriate value. + `, + }, + backgroundColor: { + type: `String`, + description: `Background color applied to the wrapper, or when "letterboxing" an image to another aspect ratio.`, + }, + fit: { + type: enums.fit.getTypeName(), + defaultValue: enums.fit.getField(`COVER`).value, + }, + }, + resolve(source, args): ReturnType { + return gatsbyImageDataResolver(source, args, store) + }, + } +} + +function sortNumeric(a: number, b: number): number { + return a - b +} + +function createSrcSetFromImages( + images: Array<{ src: string; width: number }> +): string { + return images.map(image => `${image.src} ${image.width}w`).join(`,`) +} + +// eslint-disable-next-line consistent-return +function calculateImageSizes( + sourceMetadata: ISourceMetadata, + { + width, + height, + layout, + fit, + outputPixelDensities, + breakpoints, + }: CalculateImageSizesArgs +): IImageSizes { + if (Number(width) <= 0) { + throw new Error( + `The provided width of "${width}" is incorrect. Dimensions should be a positive number.` + ) + } + + if (Number(height) <= 0) { + throw new Error( + `The provided height of "${height}" is incorrect. Dimensions should be a positive number.` + ) + } + + switch (layout) { + case `fixed`: { + return calculateFixedImageSizes({ + width, + height, + fit, + sourceMetadata, + outputPixelDensities, + }) + } + case `constrained`: { + return calculateResponsiveImageSizes({ + sourceMetadata, + width, + height, + fit, + outputPixelDensities, + layout, + }) + } + case `fullWidth`: { + return calculateResponsiveImageSizes({ + sourceMetadata, + width, + height, + fit, + outputPixelDensities, + layout, + breakpoints, + }) + } + } +} + +function calculateFixedImageSizes({ + sourceMetadata, + width, + height, + fit = `cover`, + outputPixelDensities, +}: Omit): IImageSizes { + let aspectRatio = sourceMetadata.width / sourceMetadata.height + + // make sure output outputPixelDensities has a value of 1 + outputPixelDensities.push(1) + const densities = new Set( + outputPixelDensities.sort(sortNumeric).filter(Boolean) + ) + + // If both are provided then we need to check the fit + if (width && height) { + const calculated = calculateImageDimensions(sourceMetadata, { + width, + height, + fit, + }) + width = calculated.width + height = calculated.height + aspectRatio = calculated.aspectRatio + } else { + // if we only get one value calculate the other value based on aspectRatio + if (!width) { + width = Math.round(height * aspectRatio) + } else { + height = Math.round(width / aspectRatio) + } + } + + const presentationWidth = width // will use this for presentationWidth, don't want to lose it + const isRequestedSizeLargerThanOriginal = + sourceMetadata.width < width || sourceMetadata.height < (height as number) + + // If the image is smaller than requested, warn the user that it's being processed as such + // print out this message with the necessary information before we overwrite it for sizing + if (isRequestedSizeLargerThanOriginal) { + const invalidDimension = sourceMetadata.width < width ? `width` : `height` + console.warn(` + The requested ${invalidDimension} "${ + invalidDimension === `width` ? width : height + }px" for the image ${ + sourceMetadata.filename + } was larger than the actual image ${invalidDimension} of ${ + sourceMetadata[invalidDimension] + }px. If possible, replace the current image with a larger one.`) + + if (invalidDimension === `width`) { + width = sourceMetadata.width + height = width / aspectRatio + } else { + height = sourceMetadata.height + width = height * aspectRatio + } + } + + const sizes = new Set() + for (const density of densities) { + // Screen densities can only be higher or equal to 1 + if (density >= 1) { + const widthFromDensity = density * width + sizes.add(Math.min(widthFromDensity, sourceMetadata.width)) + } + } + + return { + sizes: Array.from(sizes), + aspectRatio, + presentationWidth, + presentationHeight: Math.round(presentationWidth / aspectRatio), + unscaledWidth: width, + } +} + +function calculateResponsiveImageSizes({ + sourceMetadata, + width, + height, + fit = `cover`, + outputPixelDensities, + breakpoints, + layout, +}: ImageSizeArgs): IImageSizes { + let sizes: Array = [] + let aspectRatio = sourceMetadata.width / sourceMetadata.height + // Sort, dedupe and ensure there's a 1 + const densities = new Set( + outputPixelDensities.sort(sortNumeric).filter(Boolean) + ) + + // If both are provided then we need to check the fit + if (width && height) { + const calculated = calculateImageDimensions(sourceMetadata, { + width, + height, + fit, + }) + width = calculated.width + height = calculated.height + aspectRatio = calculated.aspectRatio + } + + // Case 1: width of height were passed in, make sure it isn't larger than the actual image + width = width && Math.round(Math.min(width, sourceMetadata.width)) + height = height && Math.min(height, sourceMetadata.height) + + const originalWidth = width + + if (breakpoints && breakpoints.length > 0) { + sizes = breakpoints.filter(size => size <= sourceMetadata.width) + + // If a larger breakpoint has been filtered-out, add the actual image width instead + if ( + sizes.length < breakpoints.length && + !sizes.includes(sourceMetadata.width) + ) { + sizes.push(sourceMetadata.width) + } + } else { + sizes = Array.from(densities).map(density => + Math.round(density * (width as number)) + ) + sizes = sizes.filter(size => size <= sourceMetadata.width) + } + + // ensure that the size passed in is included in the final output + if (layout === `constrained` && !sizes.includes(width)) { + sizes.push(width) + } + + sizes = sizes.sort(sortNumeric) + + return { + sizes, + aspectRatio, + presentationWidth: originalWidth, + presentationHeight: Math.round(originalWidth / aspectRatio), + unscaledWidth: width, + } +} + +// eslint-disable-next-line consistent-return +function getSizesAttrFromLayout(layout: ImageLayout, width: number): string { + switch (layout) { + // If screen is wider than the max size, image width is the max size, + // otherwise it's the width of the screen + case `constrained`: + return `(min-width: ${width}px) ${width}px, 100vw` + + // Image is always the same width, whatever the size of the screen + case `fixed`: + return `${width}px` + + // Image is always the width of the screen + case `fullWidth`: + return `100vw` + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/get-remote-file-enums.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/get-remote-file-enums.ts new file mode 100644 index 0000000000000..0d76c1f3017a8 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/get-remote-file-enums.ts @@ -0,0 +1,77 @@ +import type { + EnumTypeComposerAsObjectDefinition, + EnumTypeComposer, +} from "graphql-compose" + +interface IEnumArgs { + fit: EnumTypeComposer + layout: EnumTypeComposer + placeholder: EnumTypeComposer + format: EnumTypeComposer + cropFocus: EnumTypeComposer +} + +export function getRemoteFileEnums( + buildEnumType: (obj: EnumTypeComposerAsObjectDefinition) => EnumTypeComposer +): IEnumArgs { + const remoteFileFit = buildEnumType({ + name: `RemoteFileFit`, + values: { + COVER: { value: `cover` }, + FILL: { value: `fill` }, + OUTSIDE: { value: `outside` }, + CONTAIN: { value: `contain` }, + }, + }) + + const remoteFormatEnum = buildEnumType({ + name: `RemoteFileFormat`, + values: { + AUTO: { value: `auto` }, + JPG: { value: `jpg` }, + PNG: { value: `png` }, + WEBP: { value: `webp` }, + AVIF: { value: `avif` }, + }, + }) + + const remoteLayoutEnum = buildEnumType({ + name: `RemoteFileLayout`, + values: { + FIXED: { value: `fixed` }, + FULL_WIDTH: { value: `fullWidth` }, + CONSTRAINED: { value: `constrained` }, + }, + }) + + const remotePlaceholderEnum = buildEnumType({ + name: `RemoteFilePlaceholder`, + values: { + DOMINANT_COLOR: { value: `dominantColor` }, + BLURRED: { value: `blurred` }, + NONE: { value: `none` }, + }, + }) + + const remoteCropFocusEnum = buildEnumType({ + name: `RemoteFileCropFocus`, + values: { + CENTER: { value: `center` }, + TOP: { value: `top` }, + RIGHT: { value: `right` }, + BOTTOM: { value: `bottom` }, + LEFT: { value: `left` }, + ENTROPY: { value: `entropy` }, + EDGES: { value: `edges` }, + FACES: { value: `faces` }, + }, + }) + + return { + fit: remoteFileFit, + format: remoteFormatEnum, + layout: remoteLayoutEnum, + placeholder: remotePlaceholderEnum, + cropFocus: remoteCropFocusEnum, + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts new file mode 100644 index 0000000000000..a0a61a68d3b22 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/public-url-resolver.ts @@ -0,0 +1,38 @@ +import { generatePublicUrl } from "../utils/url-generator" +import { getFileExtensionFromMimeType } from "../utils/mime-type-helpers" +import { + dispatchLocalFileServiceJob, + shouldDispatch, +} from "../jobs/dispatchers" +import type { Store } from "gatsby" +import type { IRemoteFileNode, IGraphQLFieldConfigDefinition } from "../types" + +export function publicUrlResolver( + source: IRemoteFileNode, + store: Store +): string { + if (shouldDispatch()) { + dispatchLocalFileServiceJob( + { + url: source.url, + mimeType: source.mimeType, + contentDigest: source.internal.contentDigest, + }, + store + ) + } + + const extension = getFileExtensionFromMimeType(source.mimeType) + return generatePublicUrl(source) + `.${extension}` +} + +export function generatePublicUrlFieldConfig( + store: Store +): IGraphQLFieldConfigDefinition { + return { + type: `String!`, + resolve(source): string { + return publicUrlResolver(source, store) + }, + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts new file mode 100644 index 0000000000000..8ec9293e3e30c --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/resize-resolver.ts @@ -0,0 +1,110 @@ +import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" +import { getImageFormatFromMimeType } from "../utils/mime-type-helpers" +import { stripIndent } from "../utils/strip-indent" +import { + dispatchLocalImageServiceJob, + shouldDispatch, +} from "../jobs/dispatchers" +import { isImage } from "../types" +import { validateAndNormalizeFormats, calculateImageDimensions } from "./utils" + +import type { Store } from "gatsby" +import type { + IRemoteFileNode, + IGraphQLFieldConfigDefinition, + ImageFit, + ImageFormat, + ImageCropFocus, +} from "../types" +import type { getRemoteFileEnums } from "./get-remote-file-enums" + +interface IResizeArgs { + width: number + height: number + fit: ImageFit + format: ImageFormat + cropFocus: ImageCropFocus +} + +export async function resizeResolver( + source: IRemoteFileNode, + args: IResizeArgs, + store: Store +): Promise<{ + width: number + height: number + src: string +} | null> { + if (!isImage(source)) { + return null + } + + const formats = validateAndNormalizeFormats( + [args.format], + getImageFormatFromMimeType(source.mimeType) + ) + const [format] = formats + const { width, height } = calculateImageDimensions(source, args) + + if (shouldDispatch()) { + dispatchLocalImageServiceJob( + { + url: source.url, + extension: format, + ...args, + format, + contentDigest: source.internal.contentDigest, + }, + store + ) + } + + const src = `${generatePublicUrl(source)}/${generateImageArgs({ + ...args, + format, + })}.${format}` + + return { + src, + width, + height, + } +} + +export function generateResizeFieldConfig( + enums: ReturnType, + store: Store +): IGraphQLFieldConfigDefinition< + IRemoteFileNode, + ReturnType, + IResizeArgs +> { + return { + type: `RemoteFileResize`, + args: { + width: `Int`, + height: `Int`, + fit: { + type: enums.fit.getTypeName(), + defaultValue: enums.fit.getField(`COVER`).value, + }, + format: { + type: enums.format.getTypeName(), + defaultValue: enums.format.getField(`AUTO`).value, + description: stripIndent` + The image formats to generate. Valid values are AUTO (meaning the same format as the source image), JPG, PNG, WEBP and AVIF. + The default value is [AUTO, WEBP, AVIF], and you should rarely need to change this. Take care if you specify JPG or PNG when you do + not know the formats of the source images, as this could lead to unwanted results such as converting JPEGs to PNGs. Specifying + both PNG and JPG is not supported and will be ignored.`, + }, + cropFocus: { + type: enums.cropFocus.getTypeName(), + defaultValue: enums.cropFocus.getField(`EDGES`) + .value as IResizeArgs["cropFocus"], + }, + }, + resolve(source, args: IResizeArgs): ReturnType { + return resizeResolver(source, args, store) + }, + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts new file mode 100644 index 0000000000000..ee8908b131d8a --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/graphql/utils.ts @@ -0,0 +1,86 @@ +import { ImageFormat, ImageFit } from "../types" + +export function validateAndNormalizeFormats( + formats: Array, + sourceFormat: ImageFormat +): Set { + const formatSet = new Set(formats) + + // convert auto in format of source image + if (formatSet.has(`auto`)) { + formatSet.delete(`auto`) + formatSet.add(sourceFormat) + } + + if (formatSet.has(`jpg`) && formatSet.has(`png`)) { + throw new Error(`Cannot specify both JPG and PNG formats`) + } + + return formatSet +} + +/** + * Generate correct width and height like sharp will do + * @see https://sharp.pixelplumbing.com/api-resize#resize + */ +export function calculateImageDimensions( + originalDimensions: { width: number; height: number }, + { + fit, + width: requestedWidth, + height: requestedHeight, + }: { fit: ImageFit; width: number; height: number } +): { width: number; height: number; aspectRatio: number } { + // Calculate the eventual width/height of the image. + const imageAspectRatio = originalDimensions.width / originalDimensions.height + + let width = requestedWidth + let height = requestedHeight + switch (fit) { + case `cover`: { + width = requestedWidth ?? originalDimensions.width + height = requestedHeight ?? originalDimensions.height + break + } + case `inside`: { + const widthOption = requestedWidth ?? Number.MAX_SAFE_INTEGER + const heightOption = requestedHeight ?? Number.MAX_SAFE_INTEGER + + width = Math.min(widthOption, Math.round(heightOption * imageAspectRatio)) + height = Math.min( + heightOption, + Math.round(widthOption / imageAspectRatio) + ) + break + } + case `outside`: { + const widthOption = requestedWidth ?? 0 + const heightOption = requestedHeight ?? 0 + + width = Math.max(widthOption, Math.round(heightOption * imageAspectRatio)) + height = Math.max( + heightOption, + Math.round(widthOption / imageAspectRatio) + ) + break + } + + default: { + if (requestedWidth && !requestedHeight) { + width = requestedWidth + height = Math.round(requestedHeight / imageAspectRatio) + } + + if (requestedHeight && !requestedWidth) { + width = Math.round(requestedHeight * imageAspectRatio) + height = requestedHeight + } + } + } + + return { + width, + height, + aspectRatio: width / height, + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts new file mode 100644 index 0000000000000..26e0c216b1627 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/http-routes.ts @@ -0,0 +1,104 @@ +import path from "path" +import fs from "fs-extra" +import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" +import { hasFeature } from "../has-feature" +import { getFileExtensionFromMimeType } from "./utils/mime-type-helpers" +import { generateImageArgs } from "./utils/url-generator" +import { transformImage } from "./transform-images" + +import type { ImageFit } from "./types" +import type { Application } from "express" + +export function polyfillImageServiceDevRoutes(app: Application): void { + if (hasFeature(`image-cdn`)) { + return + } + + addImageRoutes(app) +} + +export function addImageRoutes(app: Application): Application { + app.get(`/_gatsby/file/:url`, async (req, res) => { + // remove the file extension + const [url] = req.params.url.split(`.`) + const outputDir = path.join( + global.__GATSBY?.root || process.cwd(), + `public`, + `_gatsby`, + `file` + ) + + const filePath = await fetchRemoteFile({ + directory: outputDir, + url: url, + name: req.params.url, + }) + fs.createReadStream(filePath).pipe(res) + }) + + app.get(`/_gatsby/image/:url/:params`, async (req, res) => { + const [params, extension] = req.params.params.split(`.`) + const url = req.params.url + + const searchParams = new URLSearchParams( + Buffer.from(params, `base64`).toString() + ) + + const resizeParams: { + width: number + height: number + format: string + fit: ImageFit + } = { + width: 0, + height: 0, + format: ``, + fit: `cover`, + } + + for (const [key, value] of searchParams) { + switch (key) { + case `w`: { + resizeParams.width = Number(value) + break + } + case `h`: { + resizeParams.height = Number(value) + break + } + case `fm`: { + resizeParams.format = value + break + } + case `fit`: { + resizeParams.fit = value as ImageFit + break + } + } + } + + const remoteUrl = Buffer.from(url, `base64`).toString() + const outputDir = path.join( + global.__GATSBY?.root || process.cwd(), + `public`, + `_gatsby`, + `_image`, + url + ) + + const filePath = await transformImage({ + outputDir, + args: { + url: remoteUrl, + filename: generateImageArgs(resizeParams) + `.${extension}`, + ...resizeParams, + }, + }) + + res.setHeader(`content-type`, getFileExtensionFromMimeType(extension)) + + fs.createReadStream(filePath).pipe(res) + }) + + return app +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts new file mode 100644 index 0000000000000..97f9c26837849 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/index.ts @@ -0,0 +1,144 @@ +import path from "path" +import { SchemaComposer } from "graphql-compose" +import { actions } from "gatsby/dist/redux/actions" +import { getRemoteFileEnums } from "./graphql/get-remote-file-enums" +import { getGatsbyVersion } from "./utils/get-gatsby-version" +import { hasFeature } from "../has-feature" +import { + generatePublicUrlFieldConfig, + publicUrlResolver, +} from "./graphql/public-url-resolver" +import { + generateResizeFieldConfig, + resizeResolver, +} from "./graphql/resize-resolver" +import { + generateGatsbyImageDataFieldConfig, + gatsbyImageDataResolver, +} from "./graphql/gatsby-image-data-resolver" + +import type { Store } from "gatsby" +import type { InterfaceTypeComposerAsObjectDefinition } from "graphql-compose" +import type { SchemaBuilder, IRemoteFileNode } from "./types" + +let enums: ReturnType | undefined + +export function getRemoteFileFields( + enums: ReturnType, + store: Store +): Record { + return { + id: `ID!`, + mimeType: `String!`, + filename: `String!`, + filesize: `Int`, + width: `Int`, + height: `Int`, + publicUrl: generatePublicUrlFieldConfig(store), + resize: generateResizeFieldConfig(enums, store), + gatsbyImageData: generateGatsbyImageDataFieldConfig(enums, store), + } +} + +function addRemoteFilePolyfillInterface< + T = ReturnType +>( + type: T, + { + schema, + store, + }: { + schema: SchemaBuilder + store: Store + } +): T { + // When the image-cdn is part of Gatsby we will only add the RemoteFile interface if necessary + if (hasFeature(`image-cdn`)) { + // @ts-ignore - wrong typing by typecomposer + if (!type.config.interfaces.includes(`RemoteFile`)) { + // @ts-ignore - wrong typing by typecomposer + type.config.interfaces.push(`RemoteFile`) + } + + return type + } + + if (!enums) { + // We only want to create the enums and interface once + const composer = new SchemaComposer() + enums = getRemoteFileEnums(composer.createEnumTC.bind(composer)) + + const types: Array< + | string + | ReturnType + | ReturnType + | ReturnType + > = [] + + for (const key in enums) { + if (enums[key]) { + types.push( + schema.buildEnumType({ + name: enums[key].getTypeName(), + values: enums[key].getFields(), + }) + ) + } + } + + types.push( + schema.buildObjectType({ + name: `RemoteFileResize`, + fields: { + width: `Int`, + height: `Int`, + src: `String`, + }, + }), + schema.buildInterfaceType({ + name: `RemoteFile`, + interfaces: [`Node`], + fields: getRemoteFileFields( + enums, + store + ) as InterfaceTypeComposerAsObjectDefinition< + IRemoteFileNode, + unknown + >["fields"], + }) + ) + + store.dispatch( + actions.createTypes(types, { + name: `gatsby`, + version: getGatsbyVersion(), + resolve: path.join(__dirname, `../`), + }) + ) + } + + // @ts-ignore - wrong typing by typecomposer + type.config.interfaces = type.config.interfaces || [] + // @ts-ignore - wrong typing by typecomposer + if (!type.config.interfaces.includes(`RemoteFile`)) { + // @ts-ignore - wrong typing by typecomposer + type.config.interfaces.push(`RemoteFile`) + } + // @ts-ignore - wrong typing by typecomposer + type.config.fields = { + // @ts-ignore - wrong typing by typecomposer + ...type.config.fields, + ...getRemoteFileFields(enums, store), + } + + return type +} + +export { polyfillImageServiceDevRoutes, addImageRoutes } from "./http-routes" +export { + getRemoteFileEnums, + addRemoteFilePolyfillInterface, + gatsbyImageDataResolver, + resizeResolver, + publicUrlResolver, +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts new file mode 100644 index 0000000000000..b49eac8166213 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/dispatchers.ts @@ -0,0 +1,107 @@ +import path from "path" +import { actions } from "gatsby/dist/redux/actions" +import { getFileExtensionFromMimeType } from "../utils/mime-type-helpers" +import { getGatsbyVersion } from "../utils/get-gatsby-version" +import { generatePublicUrl, generateImageArgs } from "../utils/url-generator" +import type { Store } from "gatsby" +import type { ImageFit } from "../types" + +export function shouldDispatch(): boolean { + return ( + !( + process.env.GATSBY_CLOUD_IMAGE_CDN === `1` || + process.env.GATSBY_CLOUD_IMAGE_CDN === `true` + ) && process.env.NODE_ENV === `production` + ) +} + +export function dispatchLocalFileServiceJob( + { + url, + mimeType, + contentDigest, + }: { url: string; mimeType: string; contentDigest: string }, + store: Store +): void { + const GATSBY_VERSION = getGatsbyVersion() + const publicUrl = generatePublicUrl({ url, mimeType }).split(`/`) + const extension = getFileExtensionFromMimeType(mimeType) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const filename = publicUrl.pop() + publicUrl.unshift(`public`) + + actions.createJobV2( + { + name: `FILE_CDN`, + inputPaths: [], + // we know it's an image so we just mimic an image + outputDir: path.join( + global.__GATSBY?.root || process.cwd(), + publicUrl.filter(Boolean).join(`/`) + ), + args: { + url, + filename: `${filename}.${extension}`, + contentDigest, + }, + }, + { + name: `gatsby`, + version: GATSBY_VERSION, + resolve: __dirname, + } + )(store.dispatch, store.getState) +} + +export function dispatchLocalImageServiceJob( + { + url, + extension, + width, + height, + format, + fit, + contentDigest, + }: { + url: string + extension: string + width: number + height: number + format: string + fit: ImageFit + contentDigest: string + }, + store: Store +): void { + const GATSBY_VERSION = getGatsbyVersion() + const publicUrl = generatePublicUrl({ + url, + mimeType: `image/${extension}`, + }).split(`/`) + publicUrl.unshift(`public`) + actions.createJobV2( + { + name: `IMAGE_CDN`, + inputPaths: [], + outputDir: path.join( + global.__GATSBY?.root || process.cwd(), + publicUrl.filter(Boolean).join(`/`) + ), + args: { + url, + filename: + generateImageArgs({ width, height, format, fit }) + `.${extension}`, + width, + height, + format, + fit, + contentDigest, + }, + }, + { + name: `gatsby`, + version: GATSBY_VERSION, + resolve: __dirname, + } + )(store.dispatch, store.getState) +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts new file mode 100644 index 0000000000000..280ae39f4da5d --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/jobs/gatsby-worker.ts @@ -0,0 +1,58 @@ +import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" +import { cpuCoreCount } from "gatsby-core-utils/cpu-core-count" +import Queue from "fastq" +import { transformImage } from "../transform-images" + +interface IImageServiceProps { + outputDir: Parameters[0]["outputDir"] + args: Parameters[0]["args"] & { + contentDigest: string + } +} + +const queue = Queue( + async function transform(task, cb): Promise { + try { + return void cb(null, await transformImage(task)) + } catch (e) { + return void cb(e) + } + }, + // When inside query workers, we only want to use the current core + process.env.GATSBY_WORKER_POOL_WORKER ? 1 : Math.max(1, cpuCoreCount() - 1) +) + +// eslint-disable-next-line @typescript-eslint/naming-convention +export async function FILE_CDN({ + outputDir, + args: { url, filename, contentDigest }, +}: { + outputDir: string + args: { url: string; filename: string; contentDigest: string } +}): Promise { + await fetchRemoteFile({ + directory: outputDir, + url: url, + name: filename, + cacheKey: contentDigest, + }) +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export async function IMAGE_CDN(args: { + outputDir: Parameters[0]["outputDir"] + args: Parameters[0]["args"] & { + contentDigest: string + } +}): Promise { + return new Promise((resolve, reject) => { + queue.push(args, err => { + if (err) { + reject(err) + return + } + + resolve() + }) + }) +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts new file mode 100644 index 0000000000000..a4e83a3be22cd --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/placeholder-handler.ts @@ -0,0 +1,262 @@ +import path from "path" +import { createReadStream, readFile, mkdtemp } from "fs-extra" +import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" +import { createMutex } from "gatsby-core-utils/mutex" +import Queue from "fastq" +import getSharpInstance from "gatsby-sharp" +import { getCache } from "./utils/cache" +import { getImageFormatFromMimeType } from "./utils/mime-type-helpers" +import type { IRemoteImageNode } from "./types" + +export enum PlaceholderType { + BLURRED = `blurred`, + DOMINANT_COLOR = `dominantColor`, +} +interface IPlaceholderGenerationArgs { + placeholderUrl: string | undefined + originalUrl: string + format: string + width: number + height: number + contentDigest: string +} + +const QUEUE_CONCURRENCY = 5 + +let tmpDir: string + +function getMutexKey(contentDigest: string): string { + return `gatsby-plugin-utils:placeholder:${contentDigest}` +} + +const queue = Queue< + undefined, + { + url: string + contentDigest: string + width: number + height: number + hasCorrectFormat: boolean + type: PlaceholderType + }, + string + // eslint-disable-next-line consistent-return +>(async function ( + { url, contentDigest, width, height, type }, + cb +): Promise { + const sharp = await getSharpInstance() + + if (!tmpDir) { + const cache = getCache() + tmpDir = await mkdtemp(path.join(cache.directory, `placeholder-`)) + } + + const filePath = await fetchRemoteFile({ + url, + cacheKey: contentDigest, + directory: tmpDir, + }) + + switch (type) { + case PlaceholderType.BLURRED: { + let buffer: Buffer + + try { + const fileStream = createReadStream(filePath) + const pipeline = sharp() + fileStream.pipe(pipeline) + buffer = await pipeline + .resize(20, Math.ceil(20 / (width / height))) + .toFormat(`jpg`) + .toBuffer() + } catch (e) { + buffer = await readFile(filePath) + } + + return cb(null, `data:image/jpg;base64,${buffer.toString(`base64`)}`) + } + case PlaceholderType.DOMINANT_COLOR: { + const fileStream = createReadStream(filePath) + const pipeline = sharp({ failOnError: false }) + fileStream.pipe(pipeline) + const { dominant } = await pipeline.stats() + + return cb( + null, + dominant + ? `rgb(${dominant.r},${dominant.g},${dominant.b})` + : `rgba(0,0,0,0)` + ) + } + } +}, +QUEUE_CONCURRENCY) + +// eslint-disable-next-line consistent-return +export async function generatePlaceholder( + source: IRemoteImageNode, + placeholderType: PlaceholderType +): Promise<{ fallback?: string; backgroundColor?: string }> { + switch (placeholderType) { + case PlaceholderType.BLURRED: { + return { + fallback: await placeholderToBase64({ + placeholderUrl: source.placeholderUrl, + originalUrl: source.url, + format: getImageFormatFromMimeType(source.mimeType), + width: source.width, + height: source.height, + contentDigest: source.internal.contentDigest, + }), + } + } + case PlaceholderType.DOMINANT_COLOR: { + return { + backgroundColor: await placeholderToDominantColor({ + placeholderUrl: source.placeholderUrl, + originalUrl: source.url, + format: getImageFormatFromMimeType(source.mimeType), + width: source.width, + height: source.height, + contentDigest: source.internal.contentDigest, + }), + } + } + } +} + +async function placeholderToBase64({ + placeholderUrl, + originalUrl, + width, + height, + contentDigest, +}: IPlaceholderGenerationArgs): Promise { + const cache = getCache() + const cacheKey = `image-cdn:${contentDigest}:base64` + const cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + + const mutex = createMutex(getMutexKey(contentDigest)) + await mutex.acquire() + + try { + let url = originalUrl + let hasWidthOrHeightAttr = false + if (placeholderUrl) { + hasWidthOrHeightAttr = + placeholderUrl.includes(`%width%`) || + placeholderUrl.includes(`%height%`) + url = generatePlaceholderUrl({ + url: placeholderUrl, + width: 20, + originalWidth: width, + originalHeight: height, + }) + } + + const base64Placeholder = await new Promise((resolve, reject) => { + queue.push( + { + url, + contentDigest, + width, + height, + hasCorrectFormat: hasWidthOrHeightAttr, + type: PlaceholderType.BLURRED, + }, + (err, result) => { + if (err) { + reject(err) + return + } + + resolve(result as string) + } + ) + }) + + cache.set(cacheKey, base64Placeholder) + + return base64Placeholder + } finally { + await mutex.release() + } +} + +async function placeholderToDominantColor({ + placeholderUrl, + originalUrl, + width, + height, + contentDigest, +}: IPlaceholderGenerationArgs): Promise { + const cache = getCache() + const cacheKey = `image-cdn:${contentDigest}:dominantColor` + const cachedValue = await cache.get(cacheKey) + if (cachedValue) { + return cachedValue + } + + const mutex = createMutex(getMutexKey(contentDigest)) + await mutex.acquire() + try { + let url = originalUrl + if (placeholderUrl) { + url = generatePlaceholderUrl({ + url: placeholderUrl, + width: 200, + originalWidth: width, + originalHeight: height, + }) + } + + const dominantColor = await new Promise((resolve, reject) => { + queue.push( + { + url, + contentDigest, + width, + height, + hasCorrectFormat: true, + type: PlaceholderType.DOMINANT_COLOR, + }, + (err, result) => { + if (err) { + reject(err) + return + } + + resolve(result as string) + } + ) + }) + + cache.set(cacheKey, dominantColor) + + return dominantColor + } finally { + await mutex.release() + } +} + +function generatePlaceholderUrl({ + url, + width, + originalWidth, + originalHeight, +}: { + url: string + width: number + originalWidth: number + originalHeight: number +}): string { + const aspectRatio = originalWidth / originalHeight + + return url + .replace(`%width%`, String(width)) + .replace(`%height%`, Math.floor(width / aspectRatio).toString()) +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts new file mode 100644 index 0000000000000..6db9152dda07e --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/transform-images.ts @@ -0,0 +1,151 @@ +import path from "path" +import { readFile, writeFile, pathExists, mkdirp } from "fs-extra" +import { fetchRemoteFile } from "gatsby-core-utils/fetch-remote-file" +import { createContentDigest } from "gatsby-core-utils/create-content-digest" +import getSharpInstance from "gatsby-sharp" +import { getCache } from "./utils/cache" + +export interface IResizeArgs { + width: number + height: number + format: string + outputPath?: string +} + +// Lots of work to get the sharp instance +type Pipeline = ReturnType>> + +// queue is used inside transformImage to batch multiple transforms together +// more info inside the transformImage function +const queue = new Map< + string, + { transforms: Array; promise: Promise } +>() + +// eslint-disable-next-line @typescript-eslint/naming-convention +export async function transformImage({ + outputDir, + args: { url, filename, contentDigest, ...args }, +}: { + outputDir: string + args: { + url: string + filename: string + width: number + height: number + format: string + fit: import("sharp").FitEnum[keyof import("sharp").FitEnum] + contentDigest?: string + } +}): Promise { + const cache = getCache() + + const digest = createContentDigest({ url, filename, contentDigest, args }) + const cacheKey = `image-cdn:` + digest + `:transform` + const cachedValue = (await cache.get(cacheKey)) as string | undefined + if (cachedValue && (await pathExists(cachedValue))) { + return cachedValue + } + + const [basename, ext] = filename.split(`.`) + const filePath = await fetchRemoteFile({ + directory: cache.directory, + url: url, + name: basename, + ext: `.${ext}`, + cacheKey: contentDigest, + }) + + const outputPath = `${outputDir}/${filename}` + await mkdirp(path.dirname(outputPath)) + + // if the queue already contains the url, we're going to add it to queue so, we can batch the transforms together. + // We use setImmediate to not block the event loop so the queue can fill up. + if (queue.has(url)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const queued = queue.get(url)! + + queued.transforms.push({ ...args, outputPath }) + + return queued.promise.then(() => { + cache.set(cacheKey, outputPath) + + return outputPath + }) + } else { + const defer = new Promise((resolve, reject) => { + setImmediate(async () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const transforms = queue.get(url)!.transforms + queue.delete(url) + + try { + await resize(await readFile(filePath), transforms) + await cache.set(cacheKey, outputPath) + + resolve(outputPath) + } catch (err) { + reject(err) + } + }) + }) + + queue.set(url, { + promise: defer, + transforms: [{ ...args, outputPath }], + }) + + return defer + } +} + +async function resizeImageWithSharp( + pipeline: Pipeline | Buffer, + { width, height, format, outputPath }: IResizeArgs +): Promise { + if (pipeline instanceof Buffer) { + if (!outputPath) { + return pipeline + } + + return writeFile(outputPath, pipeline) + } + + const resizedImage = pipeline + .resize(width, height, {}) + .toFormat( + format as unknown as keyof Awaited< + ReturnType + >["format"] + ) + + if (outputPath) { + await writeFile(outputPath, await resizedImage.toBuffer()) + return undefined + } else { + return await resizedImage.toBuffer() + } +} + +async function resize( + buffer: Buffer, + transforms: IResizeArgs | Array +): Promise> { + const sharp = await getSharpInstance() + + let pipeline: Pipeline | undefined + if (sharp) { + pipeline = sharp(buffer) + } + + if (Array.isArray(transforms)) { + const results: Array = [] + for (const transform of transforms) { + results.push(await resizeImageWithSharp(pipeline ?? buffer, transform)) + } + + return results + } else { + return resizeImageWithSharp(pipeline ?? buffer, transforms) + } +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts new file mode 100644 index 0000000000000..8f22fcfa316b4 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/types.ts @@ -0,0 +1,79 @@ +import type { Node, GatsbyNode } from "gatsby" + +export interface IRemoteFileNode extends Node { + url: string + mimeType: string + filename: string + filesize?: number +} + +export interface IRemoteImageNode extends IRemoteFileNode { + width: number + height: number + placeholderUrl?: string +} + +type GraphqlType = T extends number + ? "Int" | "Float" + : T extends boolean + ? "Boolean" + : string + +export interface IGraphQLFieldConfigDefinition< + TSource, + R, + TArgs = Record +> { + type: string + args?: { + [Property in keyof TArgs]: + | GraphqlType + | { + type: GraphqlType + description?: string + defaultValue?: TArgs[Property] + } + } + resolve(source: TSource, args: TArgs): R +} + +export type SchemaBuilder = Parameters< + NonNullable +>[0]["schema"] + +export type ImageFit = import("sharp").FitEnum[keyof import("sharp").FitEnum] +export type ImageFormat = "jpg" | "png" | "webp" | "avif" | "auto" +export type ImageLayout = "fixed" | "constrained" | "fullWidth" +export type ImageCropFocus = + | "center" + | "top" + | "right" + | "bottom" + | "left" + | "entropy" + | "edges" + | "faces" + +export type WidthOrHeight = + | { width: number; height: never } + | { width: never; height: number } + | { width: number; height: number } + +export type CalculateImageSizesArgs = WidthOrHeight & { + fit: ImageFit + layout: ImageLayout + outputPixelDensities: Array + breakpoints?: Array +} + +export function isImage(node: { + mimeType: IRemoteFileNode["mimeType"] +}): node is IRemoteImageNode { + if (!node.mimeType) { + throw new Error( + `RemoteFileNode does not have a mimeType. The field is required.` + ) + } + + return node.mimeType.startsWith(`image/`) && node.mimeType !== `image/svg+xml` +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts new file mode 100644 index 0000000000000..8e9bcc5fde181 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/cache.ts @@ -0,0 +1,6 @@ +import { getCache as getGatsbyCache } from "gatsby/dist/utils/get-cache" +import type { GatsbyCache } from "gatsby" + +export function getCache(): GatsbyCache { + return getGatsbyCache(`gatsby`) +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/get-gatsby-version.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/get-gatsby-version.ts new file mode 100644 index 0000000000000..47af2740a6d09 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/get-gatsby-version.ts @@ -0,0 +1,10 @@ +let GATSBY_VERSION: string + +export function getGatsbyVersion(): string { + if (!GATSBY_VERSION) { + const gatsbyJSON = require(`gatsby/package.json`) + GATSBY_VERSION = gatsbyJSON.version + } + + return GATSBY_VERSION +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/mime-type-helpers.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/mime-type-helpers.ts new file mode 100644 index 0000000000000..8c8f8db444019 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/mime-type-helpers.ts @@ -0,0 +1,14 @@ +import mime from "mime" + +export type ImageFormat = "jpg" | "png" | "webp" | "avif" | "auto" + +export function getImageFormatFromMimeType(mimeType: string): ImageFormat { + return mimeType + .replace(`image/jpeg`, `image/jpg`) + .replace(`image/`, ``) as ImageFormat +} + +export function getFileExtensionFromMimeType(mimeType: string): string { + // convert jpeg to jpg and make up extension if we return null + return mime.getExtension(mimeType)?.replace(`jpeg`, `jpg`) ?? `gatsby` +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/strip-indent.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/strip-indent.ts new file mode 100644 index 0000000000000..d1dcc4461b320 --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/strip-indent.ts @@ -0,0 +1,14 @@ +export function stripIndent( + tpl: ReadonlyArray, + ...expressions: ReadonlyArray +): string { + let str = `` + + tpl.forEach((chunk, index) => { + str += + chunk.replace(/^(\\n)*[ ]+/gm, `$1`) + + (expressions[index] ? expressions[index] : ``) + }) + + return str +} diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts new file mode 100644 index 0000000000000..a1b955a0dd50e --- /dev/null +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts @@ -0,0 +1,40 @@ +import { isImage } from "../types" +import type { ImageFit, WidthOrHeight } from "../types" + +export function generatePublicUrl({ + url, + mimeType, +}: { + url: string + mimeType: string +}): string { + const remoteUrl = Buffer.from(url).toString(`base64`) + + let publicUrl = isImage({ mimeType }) ? `/_gatsby/image/` : `/_gatsby/file/` + publicUrl += `${remoteUrl}` + + return publicUrl +} + +export function generateImageArgs({ + width, + height, + format, + fit, +}: WidthOrHeight & { format: string; fit: ImageFit }): string { + const args: Array = [] + if (width) { + args.push(`w=${width}`) + } + if (height) { + args.push(`h=${height}`) + } + if (fit) { + args.push(`fit=${fit}`) + } + if (format) { + args.push(`fm=${format}`) + } + + return Buffer.from(args.join(`&`)).toString(`base64`) +} diff --git a/packages/gatsby/index.d.ts b/packages/gatsby/index.d.ts index 6769f7d744bd2..dc9b48971ac09 100644 --- a/packages/gatsby/index.d.ts +++ b/packages/gatsby/index.d.ts @@ -17,7 +17,7 @@ import { GraphQLOutputType } from "graphql" import { PluginOptionsSchemaJoi, ObjectSchema } from "gatsby-plugin-utils" import { IncomingMessage, ServerResponse } from "http" -export type AvailableFeatures = never // "image-service" +export type AvailableFeatures = "image-cdn" export { default as Link, diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 8f6269c71f39a..8b4b7a80c5dca 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -166,6 +166,7 @@ "@babel/helper-plugin-utils": "^7.14.5", "@babel/register": "^7.15.3", "@types/eslint": "^8.2.1", + "@types/express": "^4.17.13", "@types/micromatch": "^4.0.1", "@types/normalize-path": "^3.0.0", "@types/reach__router": "^1.3.5", diff --git a/packages/gatsby/scripts/__tests__/api.js b/packages/gatsby/scripts/__tests__/api.js index 6b7a023f63609..bfd0008cf7104 100644 --- a/packages/gatsby/scripts/__tests__/api.js +++ b/packages/gatsby/scripts/__tests__/api.js @@ -30,7 +30,9 @@ it("generates the expected api output", done => { "wrapPageElement": Object {}, "wrapRootElement": Object {}, }, - "features": Array [], + "features": Array [ + "image-service", + ], "node": Object { "createPages": Object {}, "createPagesStatefully": Object {}, diff --git a/packages/gatsby/scripts/output-api-file.js b/packages/gatsby/scripts/output-api-file.js index 94075948aa8de..6e09e74727574 100644 --- a/packages/gatsby/scripts/output-api-file.js +++ b/packages/gatsby/scripts/output-api-file.js @@ -40,7 +40,8 @@ async function outputFile() { return merged }, {}) - output.features = []; + /** @type {Array} */ + output.features = ["image-cdn"]; return fs.writeFile( path.resolve(OUTPUT_FILE_NAME), diff --git a/packages/gatsby/src/commands/serve.ts b/packages/gatsby/src/commands/serve.ts index efeb2d637f94c..690e2b8d3b342 100644 --- a/packages/gatsby/src/commands/serve.ts +++ b/packages/gatsby/src/commands/serve.ts @@ -185,7 +185,7 @@ module.exports = async (program: IServeProgram): Promise => { express.json(), express.raw(), async (req, res, next) => { - const { "0": pathFragment } = req.params + const { "0": pathFragment } = req.params as { 0: string } // Check first for exact matches. let functionObj = functions.find( diff --git a/packages/gatsby/src/schema/schema-composer.ts b/packages/gatsby/src/schema/schema-composer.ts index a6b7a0f32ada6..db3e36a9d00c9 100644 --- a/packages/gatsby/src/schema/schema-composer.ts +++ b/packages/gatsby/src/schema/schema-composer.ts @@ -3,6 +3,7 @@ import { addDirectives, GraphQLFieldExtensionDefinition } from "./extensions" import { GraphQLDate } from "./types/date" import { IGatsbyResolverContext } from "./type-definitions" import { getNodeInterface } from "./types/node-interface" +import { getOrCreateRemoteFileInterface } from "./types/remote-file-interface" export const createSchemaComposer = ({ fieldExtensions, @@ -12,7 +13,10 @@ export const createSchemaComposer = ({ const schemaComposer: SchemaComposer> = new SchemaComposer() + // set default interfaces so plugins can use them getNodeInterface({ schemaComposer }) + getOrCreateRemoteFileInterface(schemaComposer) + schemaComposer.add(GraphQLDate) schemaComposer.add(GraphQLJSON) addDirectives({ schemaComposer, fieldExtensions }) diff --git a/packages/gatsby/src/schema/schema.js b/packages/gatsby/src/schema/schema.js index d1855e17370f8..752e4cf82a18d 100644 --- a/packages/gatsby/src/schema/schema.js +++ b/packages/gatsby/src/schema/schema.js @@ -25,6 +25,10 @@ const report = require(`gatsby-cli/lib/reporter`) const { addNodeInterfaceFields } = require(`./types/node-interface`) const { overridableBuiltInTypeNames } = require(`./types/built-in-types`) const { addInferredTypes } = require(`./infer`) +const { + addRemoteFileInterfaceFields, +} = require(`./types/remote-file-interface`) + const { findOne, findManyPaginated, @@ -202,8 +206,13 @@ const processTypeComposer = async ({ }) if (typeComposer.hasInterface(`Node`)) { - await addNodeInterfaceFields({ schemaComposer, typeComposer, parentSpan }) + await addNodeInterfaceFields({ schemaComposer, typeComposer }) } + + if (typeComposer.hasInterface(`RemoteFile`)) { + addRemoteFileInterfaceFields(schemaComposer, typeComposer) + } + await determineSearchableFields({ schemaComposer, typeComposer, @@ -247,6 +256,7 @@ const addTypes = ({ schemaComposer, types, parentSpan }) => { if (typeof typeOrTypeDef === `string`) { typeOrTypeDef = parseTypeDef(typeOrTypeDef) } + if (isASTDocument(typeOrTypeDef)) { let parsedTypes const createdFrom = `sdl` diff --git a/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts b/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts new file mode 100644 index 0000000000000..7e2289433cb0a --- /dev/null +++ b/packages/gatsby/src/schema/types/__tests__/remote-file-interface.ts @@ -0,0 +1,258 @@ +import { store } from "../../../redux" +import { build } from "../../index" +import { + DEFAULT_PIXEL_DENSITIES, + DEFAULT_BREAKPOINTS, +} from "../remote-file-interface" + +interface ISrcsetImageChunk { + url: string + params: string + descriptor: string +} + +jest.mock(`gatsby/reporter`, () => { + return { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + activityTimer: jest.fn(() => { + return { + start: jest.fn(), + setStatus: jest.fn(), + end: jest.fn(), + } + }), + phantomActivity: jest.fn(() => { + return { + start: jest.fn(), + end: jest.fn(), + } + }), + } +}) + +function extractImageChunks(url: string): { + url: string + params: string +} { + const chunks = url.split(`/`) + return { + url: Buffer.from(chunks[3], `base64`).toString(), + params: Buffer.from(chunks[4], `base64`).toString(), + } +} + +function extractImageChunksFromSrcSet( + srcSet: string +): Array { + const sources = srcSet.split(`,`) + const sourceChunks: Array = [] + for (const source of sources) { + const [url, descriptor] = source.trim().split(` `) + sourceChunks.push({ + ...extractImageChunks(url), + descriptor: descriptor ?? ``, + }) + } + + return sourceChunks +} + +describe(`remote-file`, () => { + let schema + + beforeAll(async () => { + global.__GATSBY = { + root: process.cwd(), + } + + await build({}) + schema = store.getState().schema + }) + + describe(`resize`, () => { + let resize + const remoteFile = { + url: `https://images.unsplash.com/photo-1587300003388-59208cc962cb?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=640`, + contentType: `image/jpg`, + filename: `pauline-loroy-U3aF7hgUSrk-unsplash.jpg`, + width: 1200, + height: 800, + } + + beforeAll(() => { + const fields = schema.getType(`RemoteFile`).getFields() + resize = fields.resize.resolve + }) + + it(`should resize the remote url`, async () => { + const data = await resize( + remoteFile, + { + width: 100, + height: 100, + }, + {}, + {} + ) + const { url, params } = extractImageChunks(data) + + expect(url).toEqual(remoteFile.url) + expect(params).toMatchInlineSnapshot(`"w=100&h=100&fm=jpg"`) + expect(data).toMatchInlineSnapshot( + `"/_gatsby/image/aHR0cHM6Ly9pbWFnZXMudW5zcGxhc2guY29tL3Bob3RvLTE1ODczMDAwMDMzODgtNTkyMDhjYzk2MmNiP2l4bGliPXJiLTEuMi4xJnE9ODAmZm09anBnJmNyb3A9ZW50cm9weSZjcz10aW55c3JnYiZ3PTY0MA==/dz0xMDAmaD0xMDAmZm09anBn"` + ) + }) + }) + + describe(`getImageData`, () => { + let gatsbyImageData + const remoteFile = { + url: `https://images.unsplash.com/photo-1587300003388-59208cc962cb?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=640`, + contentType: `image/jpg`, + filename: `pauline-loroy-U3aF7hgUSrk-unsplash.jpg`, + width: 1200, + height: 800, + } + + beforeAll(() => { + const fields = schema.getType(`RemoteFile`).getFields() + gatsbyImageData = fields.gatsbyImageData.resolve + }) + + it(`should get the correct fixed sizes`, async () => { + const data = await gatsbyImageData( + remoteFile, + { + layout: `fixed`, + formats: [`auto`], + width: 100, + outputPixelDensities: DEFAULT_PIXEL_DENSITIES, + }, + {}, + {} + ) + const { url: fallbackUrl, params: fallbackParams } = extractImageChunks( + data.images.fallback.src + ) + const extractedSrcSet = extractImageChunksFromSrcSet( + data.images.fallback.srcSet + ) + + expect(fallbackUrl).toBe(remoteFile.url) + expect(fallbackParams).toContain(`w=100&h=67`) + expect(data.images.fallback.sizes).toBe(`100px`) + expect(extractedSrcSet).toEqual( + expect.arrayContaining([ + { + url: remoteFile.url, + params: expect.stringContaining(`w=100&h=67`), + descriptor: `100w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=200&h=133`), + descriptor: `200w`, + }, + ]) + ) + expect(data.layout).toBe(`fixed`) + }) + + it(`should get the correct constrained sizes`, async () => { + const data = await gatsbyImageData( + remoteFile, + { + layout: `constrained`, + formats: [`auto`], + width: 100, + outputPixelDensities: DEFAULT_PIXEL_DENSITIES, + }, + {}, + {} + ) + const { url: fallbackUrl, params: fallbackParams } = extractImageChunks( + data.images.fallback.src + ) + const extractedSrcSet = extractImageChunksFromSrcSet( + data.images.fallback.srcSet + ) + + expect(fallbackUrl).toBe(remoteFile.url) + expect(fallbackParams).toContain(`w=25`) + expect(data.images.fallback.sizes).toBe(`(min-width: 100px) 100px, 100vw`) + expect(extractedSrcSet).toEqual( + expect.arrayContaining([ + { + url: remoteFile.url, + params: expect.stringContaining(`w=25&h=17`), + descriptor: `25w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=50`), + descriptor: `50w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=100`), + descriptor: `100w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=200`), + descriptor: `200w`, + }, + ]) + ) + expect(data.layout).toBe(`constrained`) + }) + + it(`should get the correct fullWidth sizes`, async () => { + const data = await gatsbyImageData( + remoteFile, + { + layout: `fullWidth`, + formats: [`auto`], + width: 100, + outputPixelDensities: DEFAULT_PIXEL_DENSITIES, + breakpoints: DEFAULT_BREAKPOINTS, + }, + {}, + {} + ) + const { url: fallbackUrl, params: fallbackParams } = extractImageChunks( + data.images.fallback.src + ) + const extractedSrcSet = extractImageChunksFromSrcSet( + data.images.fallback.srcSet + ) + + expect(fallbackUrl).toBe(remoteFile.url) + expect(fallbackParams).toContain(`w=750`) + expect(data.images.fallback.sizes).toBe(`100vw`) + expect(extractedSrcSet).toEqual( + expect.arrayContaining([ + { + url: remoteFile.url, + params: expect.stringContaining(`w=750&h=500`), + descriptor: `750w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=1080`), + descriptor: `1080w`, + }, + { + url: remoteFile.url, + params: expect.stringContaining(`w=1200`), + descriptor: `1200w`, + }, + ]) + ) + expect(data.layout).toBe(`fullWidth`) + }) + }) +}) diff --git a/packages/gatsby/src/schema/types/remote-file-interface.ts b/packages/gatsby/src/schema/types/remote-file-interface.ts new file mode 100644 index 0000000000000..27f7c175dcb1a --- /dev/null +++ b/packages/gatsby/src/schema/types/remote-file-interface.ts @@ -0,0 +1,47 @@ +import { + SchemaComposer, + ObjectTypeComposer, + InterfaceTypeComposer, +} from "graphql-compose" +import { store } from "../../redux/index" +import { + getRemoteFileEnums, + getRemoteFileFields, +} from "gatsby-plugin-utils/polyfill-remote-file" + +export const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] +export const DEFAULT_BREAKPOINTS = [750, 1080, 1366, 1920] + +export function addRemoteFileInterfaceFields( + schemaComposer: SchemaComposer, + typeComposer: ObjectTypeComposer +): void { + const remoteFileInterfaceType = getOrCreateRemoteFileInterface(schemaComposer) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeComposer.addFields(remoteFileInterfaceType.getFields() as any) +} + +export function getOrCreateRemoteFileInterface( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schemaComposer: SchemaComposer +): InterfaceTypeComposer { + const enums = getRemoteFileEnums( + schemaComposer.createEnumTC.bind(schemaComposer) + ) + + schemaComposer.getOrCreateOTC(`RemoteFileResize`, tc => { + tc.addFields({ + width: `Int`, + height: `Int`, + src: `String`, + }) + }) + + return schemaComposer.getOrCreateIFTC(`RemoteFile`, tc => { + tc.addInterface(`Node`) + tc.setDescription(`Remote Interface`) + + // @ts-ignore - types are messed up by schema composer maybe new version helps here + tc.addFields(getRemoteFileFields(enums, store)) + }) +} diff --git a/packages/gatsby/src/utils/start-server.ts b/packages/gatsby/src/utils/start-server.ts index 01158518a8f74..67e6635647cdd 100644 --- a/packages/gatsby/src/utils/start-server.ts +++ b/packages/gatsby/src/utils/start-server.ts @@ -38,7 +38,6 @@ import { import { getPageData as getPageDataExperimental } from "./get-page-data" import { findPageByPath } from "./find-page-by-path" import apiRunnerNode from "../utils/api-runner-node" -import { Express } from "express" import * as path from "path" import { Stage, IProgram } from "../commands/types" @@ -53,6 +52,8 @@ import { getServerData, IServerData } from "./get-server-data" import { ROUTES_DIRECTORY } from "../constants" import { getPageMode } from "./page-mode" import { configureTrailingSlash } from "./express-middlewares" +import type { Express } from "express" +import { addImageRoutes } from "gatsby-plugin-utils/polyfill-remote-file" type ActivityTracker = any // TODO: Replace this with proper type once reporter is typed @@ -280,12 +281,16 @@ export async function startServer( }) app.get(`/__open-stack-frame-in-editor`, (req, res) => { - const fileName = path.resolve(process.cwd(), req.query.fileName) - const lineNumber = parseInt(req.query.lineNumber, 10) - launchEditor(fileName, isNaN(lineNumber) ? 1 : lineNumber) + if (req.query.fileName) { + const fileName = path.resolve(process.cwd(), req.query.fileName as string) + const lineNumber = parseInt(req.query.lineNumber as string, 10) + launchEditor(fileName, isNaN(lineNumber) ? 1 : lineNumber) + } res.end() }) + addImageRoutes(app) + const webpackDevMiddlewareInstance = webpackDevMiddleware(compiler, { publicPath: devConfig.output.publicPath, stats: `errors-only`, @@ -414,9 +419,9 @@ export async function startServer( return } - const moduleId = req?.query?.moduleId - const lineNumber = parseInt(req.query.lineNumber, 10) - const columnNumber = parseInt(req.query.columnNumber, 10) + const moduleId = req.query?.moduleId + const lineNumber = parseInt((req.query?.lineNumber as string) ?? 1, 10) + const columnNumber = parseInt((req.query?.columnNumber as string) ?? 1, 10) let fileModule for (const module of compilation.modules) { @@ -490,9 +495,9 @@ export async function startServer( sourceContent: null, } - const filePath = req?.query?.filePath - const lineNumber = parseInt(req.query.lineNumber, 10) - const columnNumber = parseInt(req.query.columnNumber, 10) + const filePath: string | undefined = req.query?.filePath as string + const lineNumber = parseInt(req.query?.lineNumber as string, 10) + const columnNumber = parseInt(req.query?.columnNumber as string, 10) if (!filePath) { res.json(emptyResponse) @@ -603,7 +608,7 @@ export async function startServer( const renderResponse = await renderDevHTML({ path: pathObj.path, page: pathObj, - skipSsr: req.query[`skip-ssr`] || false, + skipSsr: Object.prototype.hasOwnProperty.call(req.query, `skip-ssr`), store, htmlComponentRendererPath: PAGE_RENDERER_PATH, directory: program.directory, diff --git a/yarn.lock b/yarn.lock index 5e5a9ee9524cb..fddada98cec7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3566,9 +3566,9 @@ integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== "@sindresorhus/is@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.0.0.tgz#2ff674e9611b45b528896d820d3d7a812de2f0e4" - integrity sha512-FyD2meJpDPjyNQejSjvnhpgI/azsQkA4lGbuu5BQZfjvJ9cbRZXzeWL2HceCekW4lixO9JPesIIQkSoLjeJHNQ== + version "4.4.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.4.0.tgz#e277e5bdbdf7cb1e20d320f02f5e2ed113cd3185" + integrity sha512-QppPM/8l3Mawvh4rn9CNEYIU9bxpXUCRMaX9yUpvBk1nMKusLKpfXGDEKExKaPhLzcn3lzil7pR6rnJ11HgeRQ== "@sindresorhus/slugify@^1.1.2": version "1.1.2" @@ -3802,9 +3802,9 @@ integrity sha512-PbaxAeU8SZhbVd6+IuepvyWN7KAjEThsrkdvITDxKAlN6/abIr3NW3WPzNLjJekqbVijg4YUYsyrVc84xXUHQw== "@types/cacheable-request@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.1.tgz#5d22f3dded1fd3a84c0bbeb5039a7419c2c91976" - integrity sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ== + version "6.0.2" + resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.2.tgz#c324da0197de0a98a2312156536ae262429ff6b9" + integrity sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA== dependencies: "@types/http-cache-semantics" "*" "@types/keyv" "*" @@ -3899,21 +3899,23 @@ version "1.2.0" resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86" -"@types/express-serve-static-core@*": - version "4.17.0" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.0.tgz#e80c25903df5800e926402b7e8267a675c54a281" - integrity sha512-Xnub7w57uvcBqFdIGoRg1KhNOeEj0vB6ykUM7uFWyxvbdE89GFyqgmUcanAriMr4YOxNFZBAWkfcWIb4WBPt3g== +"@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.18": + version "4.17.28" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz#c47def9f34ec81dc6328d0b1b5303d1ec98d86b8" + integrity sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig== dependencies: "@types/node" "*" + "@types/qs" "*" "@types/range-parser" "*" -"@types/express@^4.17.3": - version "4.17.3" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.3.tgz#38e4458ce2067873b09a73908df488870c303bd9" - integrity sha512-I8cGRJj3pyOLs/HndoP+25vOqhqWkAZsWMEmq1qXy/b/M3ppufecUwaK2/TVDVxcV61/iSdhykUjQQ2DLSrTdg== +"@types/express@^4.17.13": + version "4.17.13" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034" + integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA== dependencies: "@types/body-parser" "*" - "@types/express-serve-static-core" "*" + "@types/express-serve-static-core" "^4.17.18" + "@types/qs" "*" "@types/serve-static" "*" "@types/fs-extra@^9.0.13": @@ -4180,6 +4182,11 @@ version "1.5.2" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" +"@types/qs@*": + version "6.9.7" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" + integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== + "@types/range-parser@*": version "1.2.3" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" @@ -7079,10 +7086,10 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.5.4, color-string@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312" - integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA== +color-string@^1.5.4, color-string@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.0.tgz#63b6ebd1bec11999d1df3a79a7569451ac2be8aa" + integrity sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ== dependencies: color-name "^1.0.0" simple-swizzle "^0.2.2" @@ -7095,13 +7102,13 @@ color@^3.0.0, color@^3.1.1: color-convert "^1.9.1" color-string "^1.5.4" -color@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/color/-/color-4.0.1.tgz#21df44cd10245a91b1ccf5ba031609b0e10e7d67" - integrity sha512-rpZjOKN5O7naJxkH2Rx1sZzzBgaiWECc6BYXjeCE6kF0kcASJYbUq02u7JqIHwCb/j3NhV+QhRL2683aICeGZA== +color@^4.0.1, color@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.0.tgz#0c782459a3e98838ea01e4bc0fb43310ca35af78" + integrity sha512-hHTcrbvEnGjC7WBMk6ibQWFVDgEFTVmjrz2Q5HlU6ltwxv0JJN2Z8I7uRbWeQLF04dikxs8zgyZkazRJvSMtyQ== dependencies: color-convert "^2.0.1" - color-string "^1.6.0" + color-string "^1.9.0" colorette@^1.2.1, colorette@^1.2.2: version "1.3.0" @@ -8545,9 +8552,9 @@ defer-to-connect@^1.0.1: integrity sha512-k09hcQcTDY+cwgiwa6PYKLm3jlagNzQ+RSvhjzESOGOx+MNOuXkxTfEvPrO1IOQ81tArCFYQgi631clB70RpQw== defer-to-connect@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.0.tgz#83d6b199db041593ac84d781b5222308ccf4c2c1" - integrity sha512-bYL2d05vOSf1JEZNx5vSAtPuBMkX8K9EUutg7zlKvTqKXHt7RhWJFbmd7qakVuf13i+IkGmp6FwSsONOf6VYIg== + version "2.0.1" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" + integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== deferred-leveldown@~5.2.1: version "5.2.1" @@ -8699,6 +8706,12 @@ detect-indent@^6.0.0: detect-libc@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + +detect-libc@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.0.tgz#c528bc09bc6d1aa30149228240917c225448f204" + integrity sha512-S55LzUl8HUav8l9E2PBTlC5PAJrHK7tkM+XXFGD+fbsbkTzhCpG6K05LxJcUOEWzMa4v6ptcMZ9s3fOdJDu0Zw== detect-newline@^3.0.0, detect-newline@^3.1.0: version "3.1.0" @@ -10976,9 +10989,9 @@ get-stream@^5.0.0, get-stream@^5.1.0: pump "^3.0.0" get-stream@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" - integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== get-symbol-description@^1.0.0: version "1.0.0" @@ -14588,16 +14601,15 @@ livereload-js@^2.3.0: resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.3.0.tgz#c3ab22e8aaf5bf3505d80d098cbad67726548c9a" lmdb-store@^1.6.11: - version "1.6.11" - resolved "https://registry.yarnpkg.com/lmdb-store/-/lmdb-store-1.6.11.tgz#801da597af8c7a01c81f87d5cc7a7497e381236d" - integrity sha512-hIvoGmHGsFhb2VRCmfhodA/837ULtJBwRHSHKIzhMB7WtPH6BRLPsvXp1MwD3avqGzuZfMyZDUp3tccLvr721Q== + version "1.6.14" + resolved "https://registry.yarnpkg.com/lmdb-store/-/lmdb-store-1.6.14.tgz#8aa5f36fb04195f8639a3b01b32f6696867f2bc9" + integrity sha512-4woZfvfgolMEngjoMJrwePjdLotr3QKGJsDWURlJmKBed5JtE00IfAKo7ryPowl4ksGcs21pcdLkwrPnKomIuA== dependencies: + msgpackr "^1.5.0" nan "^2.14.2" node-gyp-build "^4.2.3" ordered-binary "^1.0.0" weak-lru-cache "^1.0.0" - optionalDependencies: - msgpackr "^1.4.7" lmdb@^2.1.7: version "2.2.1" @@ -16011,6 +16023,11 @@ mime@2.5.2, mime@^2.0.3, mime@^2.2.0, mime@^2.4.4, mime@^2.4.6, mime@^2.5.2: resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== +mime@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" + integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" @@ -16237,14 +16254,7 @@ msgpackr-extract@^1.0.14: nan "^2.14.2" node-gyp-build "^4.2.3" -msgpackr@^1.4.7: - version "1.4.7" - resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.4.7.tgz#d802ade841e7d2e873000b491cdda6574a3d5748" - integrity sha512-bhC8Ed1au3L3oHaR/fe4lk4w7PLGFcWQ5XY/Tk9N6tzDRz8YndjCG68TD8zcvYZoxNtw767eF/7VpaTpU9kf9w== - optionalDependencies: - msgpackr-extract "^1.0.14" - -msgpackr@^1.5.4: +msgpackr@^1.5.0, msgpackr@^1.5.4: version "1.5.4" resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.5.4.tgz#2b6ea6cb7d79c0ad98fc76c68163c48eda50cf0d" integrity sha512-Z7w5Jg+2Q9z9gJxeM68d7tSuWZZGnFIRhZnyqcZCa/1dKkhOCNvR1TUV3zzJ3+vj78vlwKRzUgVDlW4jiSOeDA== @@ -16531,10 +16541,10 @@ node-abi@^3.3.0: dependencies: semver "^7.3.5" -node-addon-api@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.2.0.tgz#117cbb5a959dff0992e1c586ae0393573e4d2a87" - integrity sha512-eazsqzwG2lskuzBqCGPi7Ac2UgOoMz8JVOXVhTvvPDYhthvNpefx8jWD8Np7Gv+2Sz0FlPWZk0nJV0z598Wn8Q== +node-addon-api@^4.2.0, node-addon-api@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" + integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== node-cleanup@^2.1.2: version "2.1.2" @@ -17143,12 +17153,7 @@ ora@^5.4.1: strip-ansi "^6.0.0" wcwidth "^1.0.1" -ordered-binary@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/ordered-binary/-/ordered-binary-1.1.3.tgz#11dbc0a4cb7f8248183b9845e031b443be82571e" - integrity sha512-tDTls+KllrZKJrqRXUYJtIcWIyoQycP7cVN7kzNNnhHKF2bMKHflcAQK+pF2Eb1iVaQodHxqZQr0yv4HWLGBhQ== - -ordered-binary@^1.2.4: +ordered-binary@^1.0.0, ordered-binary@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/ordered-binary/-/ordered-binary-1.2.4.tgz#51d3a03af078a0bdba6c7bc8f4fedd1f5d45d83e" integrity sha512-A/csN0d3n+igxBPfUrjbV5GC69LWj2pjZzAAeeHXLukQ4+fytfP4T1Lg0ju7MSPSwq7KtHkGaiwO8URZN5IpLg== @@ -18615,12 +18620,12 @@ preact@^10.6.4: resolved "https://registry.yarnpkg.com/preact/-/preact-10.6.4.tgz#ad12c409ff1b4316158486e0a7b8d43636f7ced8" integrity sha512-WyosM7pxGcndU8hY0OQlLd54tOU+qmG45QXj2dAYrL11HoyU/EzOSTlpJsirbBr1QW7lICxSsVJJmcmUglovHQ== -prebuild-install@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.0.0.tgz#3c5ce3902f1cb9d6de5ae94ca53575e4af0c1574" - integrity sha512-IvSenf33K7JcgddNz2D5w521EgO+4aMMjFt73Uk9FRzQ7P+QZPKrp7qPsDydsSwjGt3T5xRNnM1bj1zMTD5fTA== +prebuild-install@^7.0.0, prebuild-install@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.0.1.tgz#c10075727c318efe72412f333e0ef625beaf3870" + integrity sha512-QBSab31WqkyxpnMWQxubYAHR5S9B2+r81ucocew34Fkl98FhvKIF50jIJnNOBmAZfyNV7vE5T6gd3hTVWgY6tg== dependencies: - detect-libc "^1.0.3" + detect-libc "^2.0.0" expand-template "^2.0.3" github-from-package "0.0.0" minimist "^1.2.3" @@ -21574,6 +21579,20 @@ sharp@^0.29.3: tar-fs "^2.1.1" tunnel-agent "^0.6.0" +sharp@^0.30.0: + version "0.30.0" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.30.0.tgz#b2dd62d5f14088b11c72212354f31dff6511b070" + integrity sha512-L3m/l6yQFr3oGBUzcSAlN/R9yGFPYqM9FpMUe6Z4nHg4sWtP3hW1rcz+aaHklhD4wX5Jqh5PY9z+A1d4Qt3Hfg== + dependencies: + color "^4.2.0" + detect-libc "^2.0.0" + node-addon-api "^4.3.0" + prebuild-install "^7.0.1" + semver "^7.3.5" + simple-get "^4.0.1" + tar-fs "^2.1.1" + tunnel-agent "^0.6.0" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -21643,10 +21662,10 @@ simple-concat@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.0.tgz#7344cbb8b6e26fb27d66b2fc86f9f6d5997521c6" -simple-get@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.0.tgz#73fa628278d21de83dadd5512d2cc1f4872bd675" - integrity sha512-ZalZGexYr3TA0SwySsr5HlgOOinS4Jsa8YB2GJ6lUNAazyAu4KG/VmzMTwAt2YVXzzVj8QmefmAonZIK2BSGcQ== +simple-get@^4.0.0, simple-get@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== dependencies: decompress-response "^6.0.0" once "^1.3.1" @@ -24554,12 +24573,7 @@ wcwidth@^1.0.0, wcwidth@^1.0.1: dependencies: defaults "^1.0.3" -weak-lru-cache@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/weak-lru-cache/-/weak-lru-cache-1.1.2.tgz#a909a97372aabdfbfe3eb33580af255b3b198834" - integrity sha512-Bi5ae8Bev3YulgtLTafpmHmvl3vGbanRkv+qqA2AX8c3qj/MUdvSuaHq7ukDYBcMDINIaRPTPEkXSNCqqWivuA== - -weak-lru-cache@^1.2.2: +weak-lru-cache@^1.0.0, weak-lru-cache@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz#fdbb6741f36bae9540d12f480ce8254060dccd19" integrity sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==