Files
zeekayandHanzo Dev 5a8a970b16 chore(pack): drop Railway branding from our own docs, comments, and examples
Rewrites our own comments, help text, and example READMEs that named
Railway or linked railway.app so they describe what they do instead.
Retargets the changelog generator and RELEASE.md's Actions/release
links at this repo. Leaves upstream issue links, the MIT copyright,
and the builder/runtime/frontend image references untouched -- those
still point at ghcr.io/railwayapp because we don't publish our own
copies of those images yet.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-04 02:05:25 -07:00

738 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."
}
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")
}