pkg/nodejs/nodejs.go (348 lines of code) (raw):
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package nodejs contains Node.js buildpack library code.
package nodejs
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/GoogleCloudPlatform/buildpacks/pkg/cache"
"github.com/GoogleCloudPlatform/buildpacks/pkg/env"
"github.com/GoogleCloudPlatform/buildpacks/pkg/firebase/apphostingschema"
gcp "github.com/GoogleCloudPlatform/buildpacks/pkg/gcpbuildpack"
"github.com/buildpacks/libcnb/v2"
"github.com/Masterminds/semver"
"gopkg.in/yaml.v2"
)
const (
// EnvNodeEnv is the name of the NODE_ENV environment variable.
EnvNodeEnv = "NODE_ENV"
// EnvDevelopment represents a NODE_ENV development value.
EnvDevelopment = "development"
// EnvProduction represents a NODE_ENV production value.
EnvProduction = "production"
// EnvNodeVersion can be used to specify the version of Node.js is used for an app.
EnvNodeVersion = "GOOGLE_NODEJS_VERSION"
// ApphostingPreprocessedPathForPack is the path to the preprocessed apphosting.yaml file in the workspace.
ApphostingPreprocessedPathForPack = "/workspace/apphosting_preprocessed"
nodeVersionKey = "node_version"
dependencyHashKey = "dependency_hash"
// defaultVersionConstraint is used if the project does not provide a Node.js version specifier in
// their package.json or via an env var. This pins them to the active LTS version, instead of the
// the latest available version.
defaultVersionConstraint = "22.*.*"
)
// semVer11 is the smallest possible semantic version with major version 11.
var semVer11 = semver.MustParse("11.0.0")
var (
cachedPackageJSONs = map[string]*PackageJSON{}
possibleLockfileFilenames = []string{"pnpm-lock.yaml", "yarn.lock", "npm-shrinkwrap.json", "package-lock.json"}
dependencyRegex = regexp.MustCompile(`\r?\n\r?\n`)
)
type packageEnginesJSON struct {
Node string `json:"node"`
NPM string `json:"npm"`
Yarn string `json:"yarn"`
PNPM string `json:"pnpm"`
}
const (
// ScriptBuild is the name of npm build scripts.
ScriptBuild = "build"
// ScriptGCPBuild is the name of "gcp-build" scripts.
ScriptGCPBuild = "gcp-build"
// ScriptApphostingBuild is the name of "apphosting-build" scripts.
ScriptApphostingBuild = "apphosting:build"
)
// PackageJSON represents the contents of a package.json file.
type PackageJSON struct {
Name string `json:"name"`
Main string `json:"main,omitempty"`
Type string `json:"type,omitempty"`
Version string `json:"version,omitempty"`
Engines packageEnginesJSON `json:"engines,omitempty"`
Scripts map[string]string `json:"scripts"`
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
PackageManager string `json:"packageManager,omitempty"`
}
// NpmLockfile represents the contents of a lock file generated with npm.
type NpmLockfile struct {
Packages map[string]struct {
Version string `json:"version"`
} `json:"packages"`
}
// PnpmV6Lockfile represents the contents of a lock file v6 generated with pnpm.
type PnpmV6Lockfile struct {
Dependencies map[string]struct {
Version string `yaml:"version"`
} `yaml:"dependencies"`
DevDependencies map[string]struct {
Version string `yaml:"version"`
} `yaml:"devDependencies"`
}
// PnpmV9Lockfile represents the contents of a lock file v9 generated with pnpm.
type PnpmV9Lockfile struct {
Importers struct {
Dot struct {
Dependencies map[string]struct {
Version string `yaml:"version"`
} `yaml:"dependencies"`
DevDependencies map[string]struct {
Version string `yaml:"version"`
} `yaml:"devDependencies"`
} `yaml:"."`
} `yaml:"importers"`
}
// NodeDependencies represents the dependencies of a Node package via its package.json and lockfile.
type NodeDependencies struct {
PackageJSON *PackageJSON
LockfilePath string
}
// ReadPackageJSONIfExists returns deserialized package.json from the given dir. If the provided dir
// does not contain a package.json file it returns nil. Empty dir string uses the current working
// directory.
func ReadPackageJSONIfExists(dir string) (*PackageJSON, error) {
f := filepath.Join(dir, "package.json")
rawpjs, err := ioutil.ReadFile(f)
if os.IsNotExist(err) {
// Return an empty struct if the file doesn't exist (null object pattern).
return nil, nil
}
if err != nil {
return nil, gcp.InternalErrorf("reading package.json: %v", err)
}
var pjs PackageJSON
if err := json.Unmarshal(rawpjs, &pjs); err != nil {
return nil, gcp.UserErrorf("unmarshalling package.json: %v", err)
}
return &pjs, nil
}
// ReadNodeDependencies looks for a package.json and lockfile in either appDir or rootDir. The
// lockfile must either be in the same directory as package.json or be in the application root.
// TODO (b/354012293): In the future we should read the data into structs for easier manipulation.
func ReadNodeDependencies(ctx *gcp.Context, appDir string) (*NodeDependencies, error) {
rootDir := ctx.ApplicationRoot()
if !strings.HasPrefix(appDir, rootDir) {
return nil, fmt.Errorf("appDir %q is not a subpath of application root %q", appDir, rootDir)
}
var dir string
var pjs *PackageJSON
var err error
// Check appDir first for package.json file, then rootDir
if pjs, err = ReadPackageJSONIfExists(appDir); err != nil {
return nil, err
}
if pjs != nil {
dir = appDir
} else {
if pjs, err = ReadPackageJSONIfExists(rootDir); err != nil {
return nil, err
}
if pjs != nil {
dir = rootDir
} else {
return nil, gcp.UserErrorf("package.json not found")
}
}
// Try to find a lockfile from the same dir, if there is none then check the application root.
if path := findValidLockfileInDir(dir); path != "" {
return &NodeDependencies{pjs, path}, nil
}
if path := findValidLockfileInDir(rootDir); path != "" {
return &NodeDependencies{pjs, path}, nil
}
return &NodeDependencies{pjs, ""}, nil
}
func findValidLockfileInDir(dir string) string {
for _, filename := range possibleLockfileFilenames {
if fp := filepath.Join(dir, filename); isValidLockFile(fp) {
return fp
}
}
return ""
}
// isValidLockFile validates that the lock file both exists and is not empty.
func isValidLockFile(filePath string) bool {
info, err := os.Stat(filePath)
return err == nil && info.Size() > 0
}
// HasGCPBuild returns true if the given package.json file includes a "gcp-build" script.
func HasGCPBuild(p *PackageJSON) bool {
return HasScript(p, ScriptGCPBuild)
}
// HasApphostingPackageOrYamlBuild returns true if the given package.json file includes a "apphosting:build" script or if apphosting.yaml contains a build command..
func HasApphostingPackageOrYamlBuild(p *PackageJSON, apphostingSchema apphostingschema.AppHostingSchema) bool {
return HasApphostingPackageBuild(p) || apphostingSchema.Scripts.BuildCommand != ""
}
// HasApphostingPackageBuild returns true if the given package.json file includes a "apphosting:build" script.
func HasApphostingPackageBuild(p *PackageJSON) bool {
return HasScript(p, ScriptApphostingBuild)
}
// HasScript returns true if the given package.json file defines a script with the given name.
func HasScript(p *PackageJSON, name string) bool {
if p == nil {
return false
}
_, ok := p.Scripts[name]
return ok
}
// HasDevDependencies returns true if the given directory contains a package.json file that lists
// more one or more devDependencies.
func HasDevDependencies(p *PackageJSON) bool {
return p != nil && len(p.DevDependencies) > 0
}
// DependencyVersion returns the version of the given dependency in the given package.json file.
func DependencyVersion(p *PackageJSON, name string) string {
if p == nil || len(p.Dependencies) == 0 {
return ""
}
version := p.Dependencies[name]
return version
}
// RequestedNodejsVersion returns any customer provided Node.js version constraint by inspecting the
// environment and the package.json.
func RequestedNodejsVersion(ctx *gcp.Context, pjs *PackageJSON) (string, error) {
if version := os.Getenv(EnvNodeVersion); version != "" {
ctx.Logf("Using runtime version from %s: %s", EnvNodeVersion, version)
return version, nil
}
if version := os.Getenv(env.RuntimeVersion); version != "" {
ctx.Logf("Using runtime version from %s: %s", env.RuntimeVersion, version)
return version, nil
}
if pjs == nil || pjs.Engines.Node == "" {
return defaultVersionConstraint, nil
}
return pjs.Engines.Node, nil
}
// nodeVersion returns the installed version of Node.js.
// It can be overridden for testing.
var nodeVersion = func(ctx *gcp.Context) (string, error) {
result, err := ctx.Exec([]string{"node", "-v"})
if err != nil {
return "", err
}
return result.Stdout, nil
}
// isPreNode11 returns true if the installed version of Node.js is
// v10.x.x or older.
func isPreNode11(ctx *gcp.Context) (bool, error) {
nodeVer, err := nodeVersion(ctx)
if err != nil {
return false, err
}
version, err := semver.NewVersion(nodeVer)
if err != nil {
return false, gcp.InternalErrorf("failed to detect valid Node.js version %s: %v", version, err)
}
return version.LessThan(semVer11), nil
}
// NodeEnv returns the value of NODE_ENV or `production`.
func NodeEnv() string {
nodeEnv := os.Getenv(EnvNodeEnv)
if nodeEnv == "" {
nodeEnv = EnvProduction
}
return nodeEnv
}
// CheckOrClearCache checks whether cached dependencies exist and match. If they do not match, the
// layer is cleared and the layer metadata is updated with the new cache key.
func CheckOrClearCache(ctx *gcp.Context, l *libcnb.Layer, opts ...cache.Option) (bool, error) {
currentNodeVersion, err := nodeVersion(ctx)
if err != nil {
return false, err
}
opts = append(opts, cache.WithStrings(currentNodeVersion))
hash, cached, err := cache.HashAndCheck(ctx, l, dependencyHashKey, opts...)
if err != nil {
return false, err
}
if cached {
return true, nil
}
if err := ctx.ClearLayer(l); err != nil {
return false, fmt.Errorf("clearing layer: %v", err)
}
// Update the layer metadata.
cache.Add(ctx, l, dependencyHashKey, hash)
ctx.SetMetadata(l, nodeVersionKey, currentNodeVersion)
return false, nil
}
// SkipSyntaxCheck returns true if we should skip checking the user's function file for syntax errors
// if it is impacted by https://github.com/GoogleCloudPlatform/functions-framework-nodejs/issues/407.
func SkipSyntaxCheck(ctx *gcp.Context, file string, pjs *PackageJSON) (bool, error) {
nodeVer, err := nodeVersion(ctx)
if err != nil {
return false, err
}
version, err := semver.NewVersion(nodeVer)
if err != nil {
return false, gcp.InternalErrorf("failed to detect valid Node.js version %s: %v", version, err)
}
if version.Major() != 16 {
return false, nil
}
if strings.HasSuffix(file, ".mjs") {
return true, nil
}
return (pjs != nil && pjs.Type == "module"), nil
}
// IsNodeJS8Runtime returns true when the GOOGLE_RUNTIME is nodejs8. This will be
// true when using GCF or GAE with nodejs8. This function is useful for some
// legacy behavior in GCF.
func IsNodeJS8Runtime() bool {
return os.Getenv(env.Runtime) == "nodejs8"
}
func versionFromPnpmLock(rawPackageLock []byte, pkg string) (string, error) {
var lockfileV6 PnpmV6Lockfile
if err := yaml.Unmarshal(rawPackageLock, &lockfileV6); err != nil {
return "", gcp.InternalErrorf("parsing pnpm lock file: %w", err)
}
if _, ok := lockfileV6.Dependencies[pkg]; ok {
return strings.Split(lockfileV6.Dependencies[pkg].Version, "(")[0], nil
}
if _, ok := lockfileV6.DevDependencies[pkg]; ok {
return strings.Split(lockfileV6.DevDependencies[pkg].Version, "(")[0], nil
}
var lockfileV9 PnpmV9Lockfile
if err := yaml.Unmarshal(rawPackageLock, &lockfileV9); err != nil {
return "", gcp.InternalErrorf("parsing pnpm lock file: %w", err)
}
if _, ok := lockfileV9.Importers.Dot.Dependencies[pkg]; ok {
return strings.Split(lockfileV9.Importers.Dot.Dependencies[pkg].Version, "(")[0], nil
}
if _, ok := lockfileV9.Importers.Dot.DevDependencies[pkg]; ok {
return strings.Split(lockfileV9.Importers.Dot.DevDependencies[pkg].Version, "(")[0], nil
}
return "", gcp.InternalErrorf("Failed to find version for package %s in pnpm lockfile", pkg)
}
func versionFromYarnLock(rawPackageLock []byte, pjs *PackageJSON, pkg string) (string, error) {
// yarn requires custom parsing since it has a custom format
// this logic works for both yarn classic and berry
// Split using a more flexible regex to handle various newline characters across OSes
dependencies := dependencyRegex.Split(string(rawPackageLock), -1)
for _, dependency := range dependencies {
if strings.Contains(dependency, pkg+"@") && strings.Contains(dependency, pjs.Dependencies[pkg]) {
for _, line := range strings.Split(dependency, "\n") {
if strings.Contains(line, "version") {
return strings.Trim(strings.Fields(line)[1], `"`), nil
}
}
}
}
return "", gcp.InternalErrorf("Failed to find version for package %s in yarn lockfile", pkg)
}
func versionFromNpmLock(rawPackageLock []byte, pkg string) (string, error) {
var lockfile NpmLockfile
if err := json.Unmarshal(rawPackageLock, &lockfile); err != nil {
return "", gcp.InternalErrorf("parsing lock file: %w", err)
}
return lockfile.Packages["node_modules/"+pkg].Version, nil
}
// Version tries to get the concrete package version used based on lock file.
func Version(deps *NodeDependencies, pkg string) (string, error) {
raw, err := os.ReadFile(deps.LockfilePath)
if err != nil {
return "", gcp.UserErrorf("reading file at path %s: %w", deps.LockfilePath, err)
}
switch {
case strings.HasSuffix(deps.LockfilePath, "pnpm-lock.yaml"):
return versionFromPnpmLock(raw, pkg)
case strings.HasSuffix(deps.LockfilePath, "yarn.lock"):
return versionFromYarnLock(raw, deps.PackageJSON, pkg)
case strings.HasSuffix(deps.LockfilePath, "npm-shrinkwrap.json") || strings.HasSuffix(deps.LockfilePath, "package-lock.json"):
return versionFromNpmLock(raw, pkg)
}
return "", gcp.UserErrorf("Failed to find version for package %s", pkg)
}
// parsePackageManager parses the packageManager field and returns the manager name and version.
// packageManagerField must have this regex (pnpm|yarn)@\d+\.\d+\.\d+(-.+)?, e.g. pnpm@9.0.0.
func parsePackageManager(packageManagerField string) (string, string, error) {
packageManagerSplit := strings.Split(packageManagerField, "@")
if len(packageManagerSplit) != 2 {
return "", "", gcp.UserErrorf("parsing packageManager package.json field")
}
return packageManagerSplit[0], packageManagerSplit[1], nil
}
// MajorVersion returns the major version of a version string of format "major.minor.patch".
func MajorVersion(versionString string) (string, error) {
parts := strings.Split(versionString, ".")
if len(parts) < 3 {
return "", fmt.Errorf("invalid version format: %s", versionString)
}
return parts[0], nil
}
// OverrideAppHostingBuildScript overrides the "apphosting:build" script in package.json
// with the build command from the preprocessed apphosting.yaml.
func OverrideAppHostingBuildScript(ctx *gcp.Context, preprocessedApphostingPath string) (*PackageJSON, error) {
pjs, err := ReadPackageJSONIfExists(ctx.ApplicationRoot())
if err != nil {
return nil, err
}
apphostingSchema, err := apphostingschema.ReadAndValidateFromFile(preprocessedApphostingPath)
if err != nil {
return nil, err
}
if apphostingSchema.Scripts.BuildCommand == "" {
return pjs, nil
}
if pjs == nil {
pjs = &PackageJSON{}
}
if pjs.Scripts == nil {
pjs.Scripts = make(map[string]string)
}
pjs.Scripts[ScriptApphostingBuild] = apphostingSchema.Scripts.BuildCommand
marshalledJSON, err := json.Marshal(pjs)
if err != nil {
return nil, gcp.InternalErrorf("marshaling package.json: %w", err)
}
err = os.WriteFile(filepath.Join(ctx.ApplicationRoot(), "package.json"), marshalledJSON, 0644)
if err != nil {
return nil, gcp.InternalErrorf("writing package.json: %w", err)
}
return pjs, nil
}