Files
pack/core/providers/node/node.go
T
zeekay 7844a60aff refactor(pack): modernize to github.com/hanzoai/pack and remove upstream branding
- Migrate module path to github.com/hanzoai/pack across all Go packages
- Remove legacy upstream docs and competitor brand references
- Modernize LLM.md and mise.toml for pristine Hanzo Pack builder
2026-09-04 00:40:42 -07:00

739 lines
26 KiB
Go

package node
import (
"fmt"
"maps"
"path"
"regexp"
"strings"
"github.com/charmbracelet/log"
"github.com/hanzoai/pack/core/app"
"github.com/hanzoai/pack/core/generate"
"github.com/hanzoai/pack/core/plan"
"github.com/hanzoai/pack/core/resolver"
)
type PackageManager string
const (
DEFAULT_NODE_VERSION = "lts"
DEFAULT_BUN_VERSION = "latest"
// not used by npm, but many other tools: next, jest, webpack, etc
NODE_MODULES_CACHE = "/app/node_modules/.cache"
COREPACK_HOME = "/opt/corepack"
PLAYWRIGHT_CACHE_DIR = "/root/.cache/ms-playwright"
PLAYWRIGHT_INSTALL_VAR = "NODE_PLAYWRIGHT_INSTALL"
)
var nodeRuntimeDepRequirements = map[string][]string{
// To find the latest list: run `npx puppeteer@latest install --help` and inspect docs
"puppeteer": {"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"},
}
// Keep this aligned with the Debian 12 Chromium list in Playwright's nativeDeps.ts:
// https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/registry/nativeDeps.ts
// Keep these explicit because --with-deps installs them in the builder, not the runtime image.
var nodePlaywrightRuntimeDependencies = []string{
"libasound2",
"libatk-bridge2.0-0",
"libatk1.0-0",
"libatspi2.0-0",
"libcairo2",
"libcups2",
"libdbus-1-3",
"libdrm2",
"libgbm1",
"libglib2.0-0",
"libnspr4",
"libnss3",
"libpango-1.0-0",
"libx11-6",
"libxcb1",
"libxcomposite1",
"libxdamage1",
"libxext6",
"libxfixes3",
"libxkbcommon0",
"libxrandr2",
}
var (
// bunCommandRegex matches "bun" or "bunx" as a command (not part of another word)
bunCommandRegex = regexp.MustCompile(`(^|\s|;|&|&&|\||\|\|)bunx?\s`)
// PackageManifestFiles is the precedence order Railpack searches for a Node
// package manifest. package.json wins if both are present; package.json5 is
// supported because pnpm reads it natively (https://pnpm.io/package_json).
PackageManifestFiles = []string{"package.json", "package.json5"}
)
// findPackageManifest returns the first manifest file present in the app,
// honoring PackageManifestFiles precedence. Returns "" if none are present.
func findPackageManifest(app *app.App) string {
for _, f := range PackageManifestFiles {
if app.HasFile(f) {
return f
}
}
return ""
}
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)
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 findPackageManifest(ctx.App) != "", 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)
if !isSPA && !ctx.Env.IsConfigVariableTruthy("NO_SPA") && p.hasCustomStartCommand(ctx) {
// it's easy for a user to trip over this wire and not understand that it would impact SPA deployment since using the start script
// is somewhat if a railpack-convention, so let's make it clear to them.
ctx.Logger.LogInfo("Custom start command detected, skipping Caddy start")
}
miseStep := ctx.GetMiseStepBuilder()
p.InstallMisePackages(ctx, miseStep)
install := ctx.NewCommandStep("install")
install.AddInput(plan.NewStepLayer(miseStep.Name()))
p.InstallNodeDeps(ctx, install)
prune := ctx.NewCommandStep("prune")
prune.AddInput(plan.NewStepLayer(install.Name()))
prune.Secrets = []string{}
if p.shouldPrune(ctx) && !isSPA {
p.PruneNodeDeps(ctx, prune)
}
build := ctx.NewCommandStep("build")
build.AddInput(plan.NewStepLayer(install.Name()))
p.Build(ctx, build)
// note the best place for it, but it avoids having to worry about side effects in the framework helper functions
if p.usesTanstackSrvxFallback() {
ctx.Logger.LogInfo("No start script found; using srvx as production server")
ctx.Logger.LogSuggestion(
"Set up Nitro for production Node deploys",
"https://tanstack.com/start/latest/docs/framework/react/guide/hosting#nitro",
)
}
// 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
}
p.configureSvelteKit(ctx, build)
// All the files we need to include in the deploy
buildIncludeDirs := []string{"/root/.cache", "."}
if p.usesCorepack() {
buildIncludeDirs = append(buildIncludeDirs, COREPACK_HOME)
// installing corepack results in additional global node package and more symlinks (corepack prepare)
// these paths are already included via the packages:mise step, but since they are mutated when corepack is used
// we must include them again in order for pnpm and other binaries to be available in the final runtime image.
buildIncludeDirs = append(buildIncludeDirs, "/mise/installs")
buildIncludeDirs = append(buildIncludeDirs, "/mise/shims")
}
runtimeAptPackages := []string{}
if p.usesPuppeteer() {
ctx.Logger.LogInfo("Installing puppeteer packages")
runtimeAptPackages = append(runtimeAptPackages, nodeRuntimeDepRequirements["puppeteer"]...)
}
usesPlaywright := p.usesProductionPlaywright()
installPlaywright := ctx.Env.IsConfigVariableTruthy(PLAYWRIGHT_INSTALL_VAR)
// Automatic browser installation caused issues for an existing user, so keep this opt-in.
if usesPlaywright && !installPlaywright {
ctx.Logger.LogSuggestion(
"Set `RAILPACK_NODE_PLAYWRIGHT_INSTALL=1` to install Playwright browsers",
"/languages/node#playwright",
)
}
if installPlaywright {
ctx.Logger.LogInfo("Installing playwright packages and headless browser")
runtimeAptPackages = append(runtimeAptPackages, nodePlaywrightRuntimeDependencies...)
// --only-shell installs the smaller Chromium headless shell, which is
// more appropriate for server environments than full Chromium.
install.AddCommand(plan.NewExecCommand(p.packageManager.ExecCommand("playwright install --only-shell")))
}
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,
// TODO we should just have a default dockerignore/exclusion list instead of hardcoding here
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" +
"4. An Nx workspace with a Next.js app (nx.json + next)\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.\n\n" +
"For multi-app Nx workspaces, set RAILPACK_NX_APP to select which app to deploy.\n\n" +
"For more information, see the Node.js production deployment guide: https://railway.app/docs/deploy/node"
}
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"
} else if start := p.getTanstackStartCommand(); start != "" {
return start
} else if start := p.getSvelteKitStartCommand(); start != "" {
return start
} else if pkg, _, ok := p.resolveNxDeployPackage(ctx); ok {
// Prefer next start from the app dir so prune does not require the nx CLI at runtime
if p.isNextAppPackage(pkg, ctx) {
return nxNextStartCommand(pkg)
}
}
return ""
}
func (p *NodeProvider) Build(ctx *generate.GenerateContext, build *generate.CommandStepBuilder) {
build.AddInput(plan.NewLocalLayer())
if p.packageJson.HasScript("build") {
build.AddCommands([]plan.Command{
plan.NewExecCommand(p.packageManager.RunCmd("build")),
})
if p.isNext() {
// TODO I don't love how we are adding runtime config vars in `build`
build.AddVariables(map[string]string{"NEXT_TELEMETRY_DISABLED": "1"})
}
} else if _, projectName, ok := p.resolveNxDeployPackage(ctx); ok {
// TODO this `resolveNxDeployPackage` logic feels messy, we should refactor this a bit
ctx.Logger.LogInfo("Using Nx app %s", projectName)
// Nx infers targets from plugins; root package.json often has no build script
build.AddCommands([]plan.Command{
plan.NewExecCommand(nxBuildCommand(projectName)),
})
build.AddVariables(map[string]string{"NEXT_TELEMETRY_DISABLED": "1"})
}
p.addCachesToBuildStep(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))
}
}
}
// cache directories to add to the build step: if lock files are unchanged, these are pulled from cache, but cannot
// be removed in future steps.
func (p *NodeProvider) addCachesToBuildStep(ctx *generate.GenerateContext, build *generate.CommandStepBuilder) {
build.AddCache(ctx.Caches.AddCache("node-modules", NODE_MODULES_CACHE))
p.addFrameworkCaches(ctx, build, "next", func(pkg *WorkspacePackage, ctx *generate.GenerateContext) bool {
// Script-based Next apps (including monorepo packages with explicit next build)
if pkg.PackageJson.BuildScriptContains("next build") {
return true
}
// Nx and similar toolchains infer targets; detect via next dep / next.config
return p.isNextAppPackage(pkg, ctx)
}, ".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, "tanstack-start", func(pkg *WorkspacePackage, ctx *generate.GenerateContext) bool {
return p.isTanstackStartPackage(pkg)
}, "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"})
// TODO once dockerignore is in place, we should remove this
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(findPackageManifest(ctx.App)),
// 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.
// corepack *used* to be bundled with node, but as of v25 it's not, so we install it explicitly
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, p.usesCorepack())
}
// resolve node version from package.json or ENV (takes precedence over mise or railpack defaults)
func (p *NodeProvider) applyNodeVersionResolution(ctx *generate.GenerateContext, miseStep *generate.MiseStepBuilder, nodeToolRef resolver.PackageRef) {
// the order here is very important: we want to the package.json-defined node version to win out in most cases
// however, ENV-level config should always win which is why we check that ENV right after this block
if p.packageJson != nil && p.packageJson.Engines != nil && p.packageJson.Engines["node"] != "" {
miseStep.Version(nodeToolRef, p.packageJson.Engines["node"], "package.json > engines > node")
}
if envVersion, varName := ctx.Env.GetConfigVariable("NODE_VERSION"); envVersion != "" {
miseStep.Version(nodeToolRef, envVersion, varName)
}
}
// assumes that bun is the primary runtime and determines if node is needed as well
func (p *NodeProvider) needsNodeForBunInstall() bool {
// Many packages assume that Node is available. There's not a great way to detect if a package expects Node to be
// available so we just check if any packages exist in a package JSON and then install Node in that scenario
// TODO this is an extremely limited case, but we create the harness for it here so we can improve it in the future
if p.packageJson == nil {
return true
}
return len(p.packageJson.Dependencies) > 0 || len(p.packageJson.DevDependencies) > 0 || p.packageJson.hasLocalDependency()
}
func (p *NodeProvider) InstallMisePackages(ctx *generate.GenerateContext, miseStep *generate.MiseStepBuilder) {
requiresNode := p.requiresNode(ctx)
misePackages := []string{}
if p.requiresBun(ctx) {
// there isn't a bun provider, it's mixed into the node provider
// users will see a message that indicates that railpack detected node, but not that bun is selected as the runtime
// let's at least add a note the plan so users understand that the bun runtime is being used.
ctx.Logger.LogInfo("Bun runtime detected")
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)
}
// Bun projects without declared dependencies don't need node-gyp during install, so we can omit Node entirely.
if !requiresNode && p.needsNodeForBunInstall() {
requiresNode = true
}
}
// most, but not all, node project configurations result in node being installed
// for instance, if bun is used without any package.json, node is not installed
if requiresNode {
node := miseStep.Default("node", DEFAULT_NODE_VERSION)
misePackages = append(misePackages, "node")
p.applyNodeVersionResolution(ctx, miseStep, node)
// libatomic1 is required for Node.js v25+, but it's easier and harmless to install it anytime node is required
ctx.Deploy.AddAptPackages([]string{"libatomic1"})
}
p.packageManager.GetPackageManagerPackages(ctx, p.packageJson, miseStep)
// mise could override a package manager version inferred from the js environment or railpack defaults
// if it does, that version should be used within any logic in the node provider (such as changing the pnpm bin directory target)
// this is why we get the mise tool name for the package manager and pass to UseMiseVersions, which checks if the user has specified
// a version for this tool within the mise environment.
if packageManagerTool := p.packageManager.misePackageName(); packageManagerTool != "" {
misePackages = append(misePackages, packageManagerTool)
}
if p.usesCorepack() {
miseStep.AddMiseSetting("node.corepack", true)
}
// IMPORTANT because of mise support for idiomatic version files, this can easily override ENV-specified node versions
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",
// https://docs.npmjs.com/cli/using-npm/config#fetch-retries
"NPM_CONFIG_FETCH_RETRIES": "5",
"CI": "true",
}
if p.packageManager == PackageManagerYarn1 {
envVars["YARN_PRODUCTION"] = "false"
}
// TODO why are we special-casing astro here? Smells like a misunderstanding of how astro works...
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' field exists in package.json, then assume corepack unless using bun
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) usesProductionPlaywright() bool {
return p.workspace.HasProductionDependency("playwright")
}
// determine the major version of yarn from a version string. These major versions are installed and managed quite
// differently which is why we need to distinguish them here.
func parseYarnPackageManager(pmVersion string) PackageManager {
if strings.Split(pmVersion, ".")[0] == "1" {
return PackageManagerYarn1
}
// versions 2-4 are all considered part of the "Yarn Berry" release line
return PackageManagerYarnBerry
}
func (p *NodeProvider) getPackageManager(ctx *generate.GenerateContext) PackageManager {
app := ctx.App
// Check packageManager field first
if packageJson, err := p.GetPackageJson(app); err == nil && packageJson.PackageManager != nil {
pmName, pmVersion := packageJson.GetPackageManagerInfo()
if pmName == "yarn" && pmVersion != "" {
return parseYarnPackageManager(pmVersion)
} else if pmName == "pnpm" {
return PackageManagerPnpm
} else if pmName == "npm" {
return PackageManagerNpm
} else if pmName == "bun" {
return PackageManagerBun
} else if pmName == "" {
// this is mostly likely a user configuration bug, so let's at least log it in case someone is stuck
log.Info("Package manager name is empty in package.json")
} else {
log.Warnf("Unknown package manager `%s` specified in package.json, defaulting to npm", pmName)
}
}
// Fall back to file-based detection
if app.HasFile("pnpm-lock.yaml") {
return PackageManagerPnpm
} else if app.HasFile("bun.lockb") || app.HasFile("bun.lock") {
return PackageManagerBun
} else if app.HasFile(".yarnrc.yml") || app.HasFile(".yarnrc.yaml") {
return PackageManagerYarnBerry
} else if app.HasFile("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
return parseYarnPackageManager(engine)
}
}
if app.HasFile("package-lock.json") {
ctx.Logger.LogWarn("package-lock.json detected, assuming npm")
} else {
ctx.Logger.LogWarn("No node package manager detected, using npm")
}
ctx.Logger.LogSuggestion(
"Specify the package manager and version explicitly",
"/config/recommendations#specify-the-node-package-manager-version",
)
return PackageManagerNpm
}
func (p *NodeProvider) GetPackageJson(app *app.App) (*PackageJson, error) {
packageJson := NewPackageJson()
manifest := findPackageManifest(app)
if manifest == "" {
return packageJson, nil
}
err := app.ReadJSON(manifest, packageJson)
if err != nil {
return nil, fmt.Errorf("error reading %s: %w", manifest, 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) {
ctx.Metadata.Set("nodeRuntime", p.getRuntime(ctx))
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 {
// TODO why does package.json == nil trigger node?
if p.packageManager != PackageManagerBun || p.packageJson == nil || p.packageJson.PackageManager != nil {
return true
}
// TODO this check is incredibly naive
for _, script := range p.packageJson.Scripts {
if strings.Contains(script, "node") {
return true
}
}
// TODO why are these frameworks special cases?
return p.isAstro(ctx) || p.isVite(ctx)
}
// 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
}
// 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) {
// note that some of the frameworks can be both SPA and traditional node, and they are checked in the same way
if name := p.getSPAName(ctx); name != "" {
return name
}
// this can occur if a user forces SPA mode via config
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.isAstro(ctx) {
return "astro"
} 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")
}