Files
pack/core/providers/node/node.go
T
2543f736d6 feat: use mise node version when installing node alongside bun (#341)
* Use mise to install Node.js instead of apt packages

When Bun is installed but Node.js is not required in the final image,
the provider now uses mise to install Node.js instead of installing
it via apt packages. This provides better version management and
consistency with the rest of the mise-based tooling.

Key changes:
- Install Node.js via mise with default version "latest"
- Check for version specifications in NODE_VERSION env var, .nvmrc,
  and .node-version files
- Support mise.toml and .tool-versions configuration files
- Remove apt package installation of nodejs in this scenario

This ensures Node.js is available for build steps (e.g., node-gyp for
native modules) while respecting user-specified versions and providing
consistent version management across all tools.

* Simplify Node.js version detection for Bun projects

When Node.js needs to be installed for Bun projects (for build tools
like node-gyp), keep the version detection simple:

- Default to "latest"
- Let UseMiseVersions handle mise.toml and .tool-versions

Added a TODO to implement full version detection logic in the future
(NODE_VERSION env var, package.json engines, .nvmrc, .node-version).

* Only check mise versions for packages that are installed

Instead of unconditionally checking mise versions for both node and bun,
only add packages to the UseMiseVersions check if they were actually
added to the mise step. This is more efficient and makes the intent
clearer.

* Track mise packages as they're added instead of re-checking conditions

Build a list of mise packages as they're added to miseStep, then use
that list for UseMiseVersions. This avoids duplicating the conditional
logic and makes it clear which packages we're checking versions for.

* test: snapshot update

---------

Co-authored-by: Hanzo Dev <[email protected]>
2025-11-12 16:46:34 -07:00

582 lines
18 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.AddInput(ctx.NewLocalLayer())
_, 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)
}
// adds framework-specific caches for packages that match the given framework check.
// It creates uniquely named caches for each package's framework subdirectory to optimize build performance.
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, "/"), "/", "-"))
}
// in this case, pkg.Path represents the relative path to a workspace package from the root of your repository
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"),
// corepack will detect the package manager version from package.json, safe to assume the user is properly
// specifying the version they want there, no need to check other version specifications.
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)
misePackages := []string{}
// Node
if requiresNode {
node := miseStep.Default("node", DEFAULT_NODE_VERSION)
misePackages = append(misePackages, "node")
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)
misePackages = append(misePackages, "bun")
if envVersion, varName := ctx.Env.GetConfigVariable("BUN_VERSION"); envVersion != "" {
miseStep.Version(bun, envVersion, varName)
}
if bunVersionFile, err := ctx.App.ReadFile(".bun-version"); err == nil {
miseStep.Version(bun, string(bunVersionFile), ".bun-version")
}
if bunVersionFile, err := ctx.App.ReadFile(".bun-version"); err == nil {
miseStep.Version(bun, string(bunVersionFile), ".bun-version")
}
// 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
// TODO: use the same version detection logic as when bun is not in place (NODE_VERSION, package.json engines, .nvmrc, .node-version)
if !requiresNode && ctx.Config.Packages["node"] == "" {
miseStep.Default("node", "latest")
misePackages = append(misePackages, "node")
}
}
p.packageManager.GetPackageManagerPackages(ctx, p.packageJson, miseStep)
if p.usesCorepack() {
miseStep.Variables["MISE_NODE_COREPACK"] = "true"
}
// Check for mise.toml and .tool-versions and use those versions if they exist
if len(misePackages) > 0 {
miseStep.UseMiseVersions(ctx, misePackages)
}
}
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)
}
// if packageManager config exists in package.json, then assume corepack
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 {
// 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") {
return PackageManagerPnpm
} else if app.HasMatch("bun.lockb") || app.HasMatch("bun.lock") {
return PackageManagerBun
} else if app.HasMatch(".yarnrc.yml") || app.HasMatch(".yarnrc.yaml") {
return PackageManagerYarnBerry
} else if app.HasMatch("yarn.lock") {
return PackageManagerYarn1
}
// Finally, consider engines as a last-resort
if packageJson, err := p.GetPackageJson(app); err == nil && packageJson.Engines != nil {
if engine := strings.TrimSpace(packageJson.Engines["pnpm"]); engine != "" {
return PackageManagerPnpm
}
if engine := strings.TrimSpace(packageJson.Engines["bun"]); engine != "" {
return PackageManagerBun
}
if engine := strings.TrimSpace(packageJson.Engines["yarn"]); engine != "" {
// Decide yarn major: 1 -> yarn1, otherwise default to berry
major := strings.Split(engine, ".")[0]
if major == "1" {
return PackageManagerYarn1
}
return PackageManagerYarnBerry
}
}
return PackageManagerNpm
}
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) || p.isVite(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")
}