Files
pack/core/providers/node/node.go
T
Michael BiancoandGitHub 176945e95b feat: support react router build cache (#244)
* feat: add .react-router to cache path

* test: snapshot update
2025-09-02 20:09:37 -06:00

543 lines
16 KiB
Go

package node
import (
"fmt"
"maps"
"path"
"regexp"
"strings"
"github.com/railwayapp/railpack/core/app"
"github.com/railwayapp/railpack/core/generate"
"github.com/railwayapp/railpack/core/plan"
)
type PackageManager string
const (
DEFAULT_NODE_VERSION = "22"
DEFAULT_BUN_VERSION = "latest"
COREPACK_HOME = "/opt/corepack"
NODE_MODULES_CACHE = "/app/node_modules/.cache"
)
var (
// bunCommandRegex matches "bun" or "bunx" as a command (not part of another word)
bunCommandRegex = regexp.MustCompile(`(^|\s|;|&|&&|\||\|\|)bunx?\s`)
)
type NodeProvider struct {
packageJson *PackageJson
packageManager PackageManager
workspace *Workspace
}
func (p *NodeProvider) Name() string {
return "node"
}
func (p *NodeProvider) Initialize(ctx *generate.GenerateContext) error {
packageJson, err := p.GetPackageJson(ctx.App)
if err != nil {
return err
}
p.packageJson = packageJson
p.packageManager = p.getPackageManager(ctx.App)
workspace, err := NewWorkspace(ctx.App)
if err != nil {
return err
}
p.workspace = workspace
return nil
}
func (p *NodeProvider) Detect(ctx *generate.GenerateContext) (bool, error) {
return ctx.App.HasMatch("package.json"), nil
}
func (p *NodeProvider) Plan(ctx *generate.GenerateContext) error {
if p.packageJson == nil {
return fmt.Errorf("package.json not found")
}
p.SetNodeMetadata(ctx)
ctx.Logger.LogInfo("Using %s package manager", p.packageManager)
if p.workspace != nil && len(p.workspace.Packages) > 0 {
ctx.Logger.LogInfo("Found workspace with %d packages", len(p.workspace.Packages))
}
isSPA := p.isSPA(ctx)
miseStep := ctx.GetMiseStepBuilder()
p.InstallMisePackages(ctx, miseStep)
// Install
install := ctx.NewCommandStep("install")
install.AddInput(plan.NewStepLayer(miseStep.Name()))
p.InstallNodeDeps(ctx, install)
// Prune
prune := ctx.NewCommandStep("prune")
prune.AddInput(plan.NewStepLayer(install.Name()))
prune.Secrets = []string{}
if p.shouldPrune(ctx) && !isSPA {
p.PruneNodeDeps(ctx, prune)
}
// Build
build := ctx.NewCommandStep("build")
build.AddInput(plan.NewStepLayer(install.Name()))
p.Build(ctx, build)
// Deploy
ctx.Deploy.StartCmd = p.GetStartCommand(ctx)
maps.Copy(ctx.Deploy.Variables, p.GetNodeEnvVars(ctx))
// Custom deploy for SPA's
if isSPA {
err := p.DeploySPA(ctx, build)
return err
}
// All the files we need to include in the deploy
buildIncludeDirs := []string{"/root/.cache", "."}
if p.usesCorepack() {
buildIncludeDirs = append(buildIncludeDirs, COREPACK_HOME)
}
runtimeAptPackages := []string{}
if p.usesPuppeteer() {
ctx.Logger.LogInfo("Installing puppeteer dependencies")
runtimeAptPackages = append(runtimeAptPackages, "xvfb", "gconf-service", "libasound2", "libatk1.0-0", "libc6", "libcairo2", "libcups2", "libdbus-1-3", "libexpat1", "libfontconfig1", "libgbm1", "libgcc1", "libgconf-2-4", "libgdk-pixbuf2.0-0", "libglib2.0-0", "libgtk-3-0", "libnspr4", "libpango-1.0-0", "libpangocairo-1.0-0", "libstdc++6", "libx11-6", "libx11-xcb1", "libxcb1", "libxcomposite1", "libxcursor1", "libxdamage1", "libxext6", "libxfixes3", "libxi6", "libxrandr2", "libxrender1", "libxss1", "libxtst6", "ca-certificates", "fonts-liberation", "libappindicator1", "libnss3", "lsb-release", "xdg-utils", "wget")
}
nodeModulesLayer := plan.NewStepLayer(build.Name(), plan.Filter{
Include: p.packageManager.GetInstallFolder(ctx),
})
if p.shouldPrune(ctx) {
nodeModulesLayer = plan.NewStepLayer(prune.Name(), plan.Filter{
Include: p.packageManager.GetInstallFolder(ctx),
})
}
buildLayer := plan.NewStepLayer(build.Name(), plan.Filter{
Include: buildIncludeDirs,
Exclude: []string{"node_modules", ".yarn"},
})
ctx.Deploy.AddAptPackages(runtimeAptPackages)
ctx.Deploy.AddInputs([]plan.Layer{
miseStep.GetLayer(),
nodeModulesLayer,
buildLayer,
})
return nil
}
func (p *NodeProvider) StartCommandHelp() string {
return "To configure your start command, Railpack will check:\n\n" +
"1. A \"start\" script in your package.json:\n" +
" \"scripts\": {\n" +
" \"start\": \"node index.js\"\n" +
" }\n\n" +
"2. A \"main\" field in your package.json pointing to your entry file:\n" +
" \"main\": \"src/server.js\"\n\n" +
"3. An index.js or index.ts file in your project root\n\n" +
"If you have a static site, you can set the RAILPACK_SPA_OUTPUT_DIR environment variable\n" +
"containing the directory of your built static files."
}
func (p *NodeProvider) GetStartCommand(ctx *generate.GenerateContext) string {
if start := p.getScripts(p.packageJson, "start"); start != "" {
return p.packageManager.RunCmd("start")
} else if main := p.packageJson.Main; main != "" {
return p.packageManager.RunScriptCommand(main)
} else if files, err := ctx.App.FindFiles("{index.js,index.ts}"); err == nil && len(files) > 0 {
return p.packageManager.RunScriptCommand(files[0])
} else if p.isNuxt() {
// Default Nuxt start command
return "node .output/server/index.mjs"
}
return ""
}
func (p *NodeProvider) Build(ctx *generate.GenerateContext, build *generate.CommandStepBuilder) {
build.AddCommand(plan.NewCopyCommand("."))
_, ok := p.packageJson.Scripts["build"]
if ok {
build.AddCommands([]plan.Command{
plan.NewExecCommand(p.packageManager.RunCmd("build")),
})
if p.isNext() {
build.AddVariables(map[string]string{"NEXT_TELEMETRY_DISABLED": "1"})
}
}
p.addCaches(ctx, build)
}
func (p *NodeProvider) addFrameworkCaches(ctx *generate.GenerateContext, build *generate.CommandStepBuilder, frameworkName string, frameworkCheck func(*WorkspacePackage, *generate.GenerateContext) bool, cacheSubPath string) {
if packages, err := p.getPackagesWithFramework(ctx, frameworkCheck); err == nil {
for _, pkg := range packages {
var cacheName string
if pkg.Path == "" {
cacheName = frameworkName
} else {
cacheName = fmt.Sprintf("%s-%s", frameworkName, strings.ReplaceAll(strings.TrimSuffix(pkg.Path, "/"), "/", "-"))
}
cacheDir := path.Join("/app", pkg.Path, cacheSubPath)
build.AddCache(ctx.Caches.AddCache(cacheName, cacheDir))
}
}
}
func (p *NodeProvider) addCaches(ctx *generate.GenerateContext, build *generate.CommandStepBuilder) {
build.AddCache(ctx.Caches.AddCache("node-modules", "/app/node_modules/.cache"))
p.addFrameworkCaches(ctx, build, "next", func(pkg *WorkspacePackage, ctx *generate.GenerateContext) bool {
if pkg.PackageJson.HasScript("build") {
return strings.Contains(pkg.PackageJson.Scripts["build"], "next build")
}
return false
}, ".next/cache")
p.addFrameworkCaches(ctx, build, "remix", func(pkg *WorkspacePackage, ctx *generate.GenerateContext) bool {
return pkg.PackageJson.hasDependency("@remix-run/node")
}, ".cache")
p.addFrameworkCaches(ctx, build, "vite", func(pkg *WorkspacePackage, ctx *generate.GenerateContext) bool {
return p.isVitePackage(pkg, ctx)
}, "node_modules/.vite")
p.addFrameworkCaches(ctx, build, "astro", func(pkg *WorkspacePackage, ctx *generate.GenerateContext) bool {
return p.isAstroPackage(pkg, ctx)
}, "node_modules/.astro")
p.addFrameworkCaches(ctx, build, "react-router", func(pkg *WorkspacePackage, ctx *generate.GenerateContext) bool {
return p.isReactRouterPackage(pkg, ctx)
}, ".react-router")
}
func (p *NodeProvider) shouldPrune(ctx *generate.GenerateContext) bool {
return ctx.Env.IsConfigVariableTruthy("PRUNE_DEPS")
}
func (p *NodeProvider) PruneNodeDeps(ctx *generate.GenerateContext, prune *generate.CommandStepBuilder) {
ctx.Logger.LogInfo("Pruning node dependencies")
prune.Variables["NPM_CONFIG_PRODUCTION"] = "true"
prune.Secrets = []string{}
p.packageManager.PruneDeps(ctx, prune)
}
func (p *NodeProvider) InstallNodeDeps(ctx *generate.GenerateContext, install *generate.CommandStepBuilder) {
maps.Copy(install.Variables, p.GetNodeEnvVars(ctx))
install.Secrets = []string{}
install.UseSecretsWithPrefixes([]string{"NODE", "NPM", "BUN", "PNPM", "YARN", "CI"})
install.AddPaths([]string{"/app/node_modules/.bin"})
if ctx.App.HasMatch("node_modules") {
ctx.Logger.LogWarn("node_modules directory found in project root, this is likely a mistake")
ctx.Logger.LogWarn("It is recommended to add node_modules to the .gitignore file")
}
if p.usesCorepack() {
pmName, pmVersion := p.packageJson.GetPackageManagerInfo()
install.AddVariables(map[string]string{
"COREPACK_HOME": COREPACK_HOME,
})
ctx.Logger.LogInfo("Installing %s@%s with Corepack", pmName, pmVersion)
install.AddCommands([]plan.Command{
plan.NewCopyCommand("package.json"),
plan.NewExecShellCommand("npm i -g corepack@latest && corepack enable && corepack prepare --activate"),
})
}
install.AddCommands([]plan.Command{
// it's possible for a package.json to exist without any dependencies, in which case node_modules is not generated
// and bun.lockb, etc are not generated either. However, this path is used to compute the cache key, so we ensure
// it exists on the filesystem to avoid a docker cache key computation error.
plan.NewExecCommand(fmt.Sprintf("mkdir -p %s", NODE_MODULES_CACHE)),
})
p.packageManager.installDependencies(ctx, p.workspace, install)
}
func (p *NodeProvider) InstallMisePackages(ctx *generate.GenerateContext, miseStep *generate.MiseStepBuilder) {
requiresNode := p.requiresNode(ctx)
// Node
if requiresNode {
node := miseStep.Default("node", DEFAULT_NODE_VERSION)
if envVersion, varName := ctx.Env.GetConfigVariable("NODE_VERSION"); envVersion != "" {
miseStep.Version(node, envVersion, varName)
}
if p.packageJson != nil && p.packageJson.Engines != nil && p.packageJson.Engines["node"] != "" {
miseStep.Version(node, p.packageJson.Engines["node"], "package.json > engines > node")
}
if nvmrc, err := ctx.App.ReadFile(".nvmrc"); err == nil {
if len(nvmrc) > 0 && nvmrc[0] == 'v' {
nvmrc = nvmrc[1:]
}
miseStep.Version(node, string(nvmrc), ".nvmrc")
}
if nodeVersionFile, err := ctx.App.ReadFile(".node-version"); err == nil {
miseStep.Version(node, string(nodeVersionFile), ".node-version")
}
}
// Bun
if p.requiresBun(ctx) {
bun := miseStep.Default("bun", DEFAULT_BUN_VERSION)
if envVersion, varName := ctx.Env.GetConfigVariable("BUN_VERSION"); envVersion != "" {
miseStep.Version(bun, envVersion, varName)
}
// If we don't need node in the final image, we still want to include it for the install steps
// since many packages need node-gyp to install native modules
// in this case, we don't need a specific version, so we'll just pull from apt
if !requiresNode && ctx.Config.Packages["node"] == "" {
miseStep.AddSupportingAptPackage("nodejs")
}
}
p.packageManager.GetPackageManagerPackages(ctx, p.packageJson, miseStep)
if p.usesCorepack() {
miseStep.Variables["MISE_NODE_COREPACK"] = "true"
}
}
func (p *NodeProvider) GetNodeEnvVars(ctx *generate.GenerateContext) map[string]string {
envVars := map[string]string{
"NODE_ENV": "production",
"NPM_CONFIG_PRODUCTION": "false",
"NPM_CONFIG_UPDATE_NOTIFIER": "false",
"NPM_CONFIG_FUND": "false",
"CI": "true",
}
if p.packageManager == PackageManagerYarn1 {
envVars["YARN_PRODUCTION"] = "false"
}
if p.isAstro(ctx) && !p.isAstroSPA(ctx) {
maps.Copy(envVars, p.getAstroEnvVars())
}
return envVars
}
func (p *NodeProvider) hasDependency(dependency string) bool {
return p.packageJson.hasDependency(dependency)
}
func (p *NodeProvider) usesCorepack() bool {
return p.packageJson != nil && p.packageJson.PackageManager != nil && p.packageManager != PackageManagerBun
}
func (p *NodeProvider) usesPuppeteer() bool {
return p.workspace.HasDependency("puppeteer")
}
func (p *NodeProvider) getPackageManager(app *app.App) PackageManager {
packageManager := PackageManagerNpm
// Check packageManager field first
if packageJson, err := p.GetPackageJson(app); err == nil && packageJson.PackageManager != nil {
pmName, pmVersion := packageJson.GetPackageManagerInfo()
if pmName == "yarn" && pmVersion != "" {
majorVersion := strings.Split(pmVersion, ".")[0]
if majorVersion == "1" {
return PackageManagerYarn1
} else {
return PackageManagerYarnBerry
}
} else if pmName == "pnpm" {
return PackageManagerPnpm
} else if pmName == "bun" {
return PackageManagerBun
}
}
// Fall back to file-based detection
if app.HasMatch("pnpm-lock.yaml") {
packageManager = PackageManagerPnpm
} else if app.HasMatch("bun.lockb") || app.HasMatch("bun.lock") {
packageManager = PackageManagerBun
} else if app.HasMatch(".yarnrc.yml") || app.HasMatch(".yarnrc.yaml") {
packageManager = PackageManagerYarnBerry
} else if app.HasMatch("yarn.lock") {
packageManager = PackageManagerYarn1
}
return packageManager
}
func (p *NodeProvider) GetPackageJson(app *app.App) (*PackageJson, error) {
packageJson := NewPackageJson()
if !app.HasMatch("package.json") {
return packageJson, nil
}
err := app.ReadJSON("package.json", packageJson)
if err != nil {
return nil, fmt.Errorf("error reading package.json: %w", err)
}
return packageJson, nil
}
func (p *NodeProvider) getScripts(packageJson *PackageJson, name string) string {
if scripts := packageJson.Scripts; scripts != nil {
if script, ok := scripts[name]; ok {
return script
}
}
return ""
}
func (p *NodeProvider) SetNodeMetadata(ctx *generate.GenerateContext) {
runtime := p.getRuntime(ctx)
spaFramework := p.getSPAFramework(ctx)
ctx.Metadata.Set("nodeRuntime", runtime)
ctx.Metadata.Set("nodeSPAFramework", spaFramework)
ctx.Metadata.Set("nodePackageManager", string(p.packageManager))
ctx.Metadata.SetBool("nodeIsSPA", p.isSPA(ctx))
ctx.Metadata.SetBool("nodeUsesCorepack", p.usesCorepack())
}
func (p *NodeProvider) getPackagesWithFramework(ctx *generate.GenerateContext, frameworkCheck func(*WorkspacePackage, *generate.GenerateContext) bool) ([]*WorkspacePackage, error) {
packages := []*WorkspacePackage{}
// Check root package first
if p.workspace != nil && frameworkCheck(p.workspace.Root, ctx) {
packages = append(packages, p.workspace.Root)
}
// Check workspace packages
if p.workspace != nil {
for _, pkg := range p.workspace.Packages {
if frameworkCheck(pkg, ctx) {
packages = append(packages, pkg)
}
}
}
return packages, nil
}
func (p *NodeProvider) requiresNode(ctx *generate.GenerateContext) bool {
if p.packageManager != PackageManagerBun || p.packageJson == nil || p.packageJson.PackageManager != nil {
return true
}
for _, script := range p.packageJson.Scripts {
if strings.Contains(script, "node") {
return true
}
}
return p.isAstro(ctx)
}
// packageJsonRequiresBun checks if a package.json's scripts use bun commands
func packageJsonRequiresBun(packageJson *PackageJson) bool {
if packageJson == nil || packageJson.Scripts == nil {
return false
}
for _, script := range packageJson.Scripts {
if bunCommandRegex.MatchString(script) {
return true
}
}
return false
}
// requiresBun checks if bun should be installed and available for the build and final image
func (p *NodeProvider) requiresBun(ctx *generate.GenerateContext) bool {
if p.packageManager == PackageManagerBun {
return true
}
if packageJsonRequiresBun(p.packageJson) {
return true
}
if ctx.Config.Deploy != nil && bunCommandRegex.MatchString(ctx.Config.Deploy.StartCmd) {
return true
}
return false
}
func (p *NodeProvider) getRuntime(ctx *generate.GenerateContext) string {
if p.isSPA(ctx) {
if p.isAstro(ctx) {
return "astro"
} else if p.isVite(ctx) {
return "vite"
} else if p.isCRA(ctx) {
return "cra"
} else if p.isAngular(ctx) {
return "angular"
} else if p.isReactRouter(ctx) {
return "react-router"
}
return "static"
} else if p.isNext() {
return "next"
} else if p.isNuxt() {
return "nuxt"
} else if p.isRemix() {
return "remix"
} else if p.isTanstackStart() {
return "tanstack-start"
} else if p.isVite(ctx) {
return "vite"
} else if p.isReactRouter(ctx) {
return "react-router"
} else if p.packageManager == PackageManagerBun {
return "bun"
}
return "node"
}
func (p *NodeProvider) isNext() bool {
return p.hasDependency("next")
}
func (p *NodeProvider) isNuxt() bool {
return p.hasDependency("nuxt")
}
func (p *NodeProvider) isRemix() bool {
return p.hasDependency("@remix-run/node")
}
func (p *NodeProvider) isTanstackStart() bool {
return p.hasDependency("@tanstack/react-start")
}