mirror of
https://github.com/hanzoai/pack.git
synced 2026-09-30 23:43:13 +00:00
489 lines
13 KiB
Go
489 lines
13 KiB
Go
package node
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"path"
|
|
"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"
|
|
)
|
|
|
|
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.NewStepInput(miseStep.Name()))
|
|
p.InstallNodeDeps(ctx, install)
|
|
|
|
// Prune
|
|
prune := ctx.NewCommandStep("prune")
|
|
prune.AddInput(plan.NewStepInput(install.Name()))
|
|
prune.Secrets = []string{}
|
|
if p.shouldPrune(ctx) && !isSPA {
|
|
p.PruneNodeDeps(ctx, prune)
|
|
}
|
|
|
|
// Build
|
|
build := ctx.NewCommandStep("build")
|
|
build.AddInput(plan.NewStepInput(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)
|
|
}
|
|
|
|
if p.packageManager == PackageManagerYarn2 {
|
|
buildIncludeDirs = append(buildIncludeDirs, p.packageManager.getYarn2GlobalFolder(ctx))
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
nodeModulesInput := plan.NewStepInput(build.Name(), plan.InputOptions{
|
|
Include: p.packageManager.GetInstallFolder(ctx),
|
|
})
|
|
if p.shouldPrune(ctx) {
|
|
nodeModulesInput = plan.NewStepInput(prune.Name(), plan.InputOptions{
|
|
Include: p.packageManager.GetInstallFolder(ctx),
|
|
})
|
|
}
|
|
|
|
buildInput := plan.NewStepInput(build.Name(), plan.InputOptions{
|
|
Include: buildIncludeDirs,
|
|
Exclude: []string{"node_modules", ".yarn"},
|
|
})
|
|
|
|
ctx.Deploy.Inputs = []plan.Input{
|
|
ctx.DefaultRuntimeInputWithPackages(runtimeAptPackages),
|
|
plan.NewStepInput(miseStep.Name(), plan.InputOptions{
|
|
Include: miseStep.GetOutputPaths(),
|
|
}),
|
|
nodeModulesInput,
|
|
buildInput,
|
|
}
|
|
|
|
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) addCaches(ctx *generate.GenerateContext, build *generate.CommandStepBuilder) {
|
|
build.AddCache(ctx.Caches.AddCache("node-modules", "/app/node_modules/.cache"))
|
|
|
|
if nextApps, err := p.getNextApps(ctx); err == nil {
|
|
for _, nextApp := range nextApps {
|
|
nextCacheDir := path.Join("/app", nextApp, ".next/cache")
|
|
build.AddCache(ctx.Caches.AddCache(fmt.Sprintf("next-%s", nextApp), nextCacheDir))
|
|
}
|
|
}
|
|
|
|
if p.isRemix() {
|
|
build.AddCache(ctx.Caches.AddCache("remix", ".cache"))
|
|
}
|
|
|
|
if p.isAstro(ctx) {
|
|
build.AddCache(p.getAstroCache(ctx))
|
|
}
|
|
|
|
if p.isVite(ctx) {
|
|
build.AddCache(p.getViteCache(ctx))
|
|
}
|
|
|
|
if p.isNuxt() {
|
|
build.AddCache(ctx.Caches.AddCache("nuxt", ".nuxt"))
|
|
}
|
|
}
|
|
|
|
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 p.usesCorepack() {
|
|
install.AddVariables(map[string]string{
|
|
"COREPACK_HOME": COREPACK_HOME,
|
|
})
|
|
ctx.Logger.LogInfo("Using Corepack")
|
|
|
|
install.AddCommands([]plan.Command{
|
|
plan.NewCopyCommand("package.json"),
|
|
plan.NewExecShellCommand("npm i -g corepack@latest && corepack enable && corepack prepare --activate"),
|
|
})
|
|
}
|
|
|
|
p.packageManager.installDependencies(ctx, p.packageJson, 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.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")
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
envVars["MISE_YARN_SKIP_GPG"] = "true" // https://github.com/mise-plugins/mise-yarn/pull/8
|
|
}
|
|
|
|
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.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
|
|
|
|
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 = PackageManagerYarn2
|
|
} 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) getNextApps(ctx *generate.GenerateContext) ([]string, error) {
|
|
nextPaths, err := p.filterPackageJson(ctx, func(packageJson *PackageJson) bool {
|
|
if packageJson.HasScript("build") {
|
|
return strings.Contains(packageJson.Scripts["build"], "next build")
|
|
}
|
|
|
|
return false
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return nextPaths, nil
|
|
}
|
|
|
|
func (p *NodeProvider) filterPackageJson(ctx *generate.GenerateContext, filterFunc func(packageJson *PackageJson) bool) ([]string, error) {
|
|
filteredPaths := []string{}
|
|
|
|
files, err := ctx.App.FindFiles("**/package.json")
|
|
if err != nil {
|
|
return filteredPaths, err
|
|
}
|
|
|
|
for _, file := range files {
|
|
var packageJson PackageJson
|
|
err := ctx.App.ReadJSON(file, &packageJson)
|
|
if err != nil {
|
|
return filteredPaths, err
|
|
}
|
|
|
|
if filterFunc(&packageJson) {
|
|
dirPath := strings.TrimSuffix(file, "package.json")
|
|
filteredPaths = append(filteredPaths, dirPath)
|
|
}
|
|
}
|
|
|
|
return filteredPaths, 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)
|
|
}
|
|
|
|
func (p *NodeProvider) requiresBun(ctx *generate.GenerateContext) bool {
|
|
if p.packageManager == PackageManagerBun {
|
|
return true
|
|
}
|
|
|
|
for _, script := range p.packageJson.Scripts {
|
|
if strings.Contains(script, "bun") {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if ctx.Config.Deploy != nil && strings.Contains(ctx.Config.Deploy.StartCmd, "bun") {
|
|
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"
|
|
}
|
|
|
|
return "static"
|
|
} else if p.isNext() {
|
|
return "next"
|
|
} else if p.isNuxt() {
|
|
return "nuxt"
|
|
} else if p.isRemix() {
|
|
return "remix"
|
|
} else if p.isVite(ctx) {
|
|
return "vite"
|
|
} 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")
|
|
}
|