diff --git a/pkg/stackbuild/node.go b/pkg/stackbuild/node.go index 57f2da463..35dc95803 100644 --- a/pkg/stackbuild/node.go +++ b/pkg/stackbuild/node.go @@ -75,6 +75,7 @@ type NodeStack struct { packageManager nodePackageManager scripts map[string]string entryPoint string + hasNext bool // Parsed dependencies from package.json dependencies map[string]string @@ -131,6 +132,13 @@ func (s *NodeStack) Init(opts BuildOptions) { } } + // Detect Next.js so we can run its production build and serve it with + // `next start`, mirroring how the Ruby stack special-cases Rails. + if s.hasDependency("next") { + s.hasNext = true + s.Event("framework", "nextjs", "Next.js framework detected") + } + // Check for common entry points and store the first one found for _, entry := range []string{"index.ts", "index.js", "server.ts", "server.js", "app.ts", "app.js", "main.ts", "main.js"} { if s.hasFile(entry) { @@ -203,11 +211,49 @@ func (s *NodeStack) GenerateLLB(dir string, opts BuildOptions) (*llb.State, erro state = h.copyApp(state, localCtx) + // Run the framework build (e.g. `next build`) after the app is copied. + // Inject user env vars so build-time values (e.g. NEXT_PUBLIC_* that Next + // inlines at build time) are available, mirroring how the Ruby stack + // injects env vars for asset precompilation. + if buildCmd := s.frameworkBuildCommand(); buildCmd != "" { + build := state.Dir("/app") + for k, v := range opts.EnvVars { + build = build.AddEnv(k, v) + } + state = build.Run( + llb.Shlex(buildCmd), + llb.WithCustomName("[phase] Building Next.js application"), + ).Root() + } + state = s.applyOnBuild(state, opts) return &state, nil } +// hasDependency reports whether the package.json lists the given package in +// either dependencies or devDependencies. +func (s *NodeStack) hasDependency(name string) bool { + if _, ok := s.dependencies[name]; ok { + return true + } + _, ok := s.devDependencies[name] + return ok +} + +// frameworkBuildCommand returns the in-image build command for a detected +// framework, or "" when no build step is needed. Next.js must be compiled with +// `next build` before it can be served; a plain Node app is copied as-is. +func (s *NodeStack) frameworkBuildCommand() string { + if !s.hasNext { + return "" + } + if s.packageManager == nodePkgYarn { + return "yarn build" + } + return "npm run build" +} + func (s *NodeStack) parsePackageJSON() { data, err := s.readFile("package.json") if err != nil { @@ -229,6 +275,16 @@ func (s *NodeStack) parsePackageJSON() { } func (s *NodeStack) WebCommand() string { + // Next.js is served with `next start`; bind to the platform-provided port. + // Resolve the local `next` binary through the detected package manager so a + // yarn project doesn't shell out to npm's npx (matches frameworkBuildCommand). + if s.hasNext { + if s.packageManager == nodePkgYarn { + return "yarn next start -p $PORT" + } + return "npx next start -p $PORT" + } + // Determine the runner based on detected package manager var runner string if s.packageManager == nodePkgYarn { diff --git a/pkg/stackbuild/stackbuild_test.go b/pkg/stackbuild/stackbuild_test.go index f96343edc..963842aa5 100644 --- a/pkg/stackbuild/stackbuild_test.go +++ b/pkg/stackbuild/stackbuild_test.go @@ -405,6 +405,82 @@ func TestNode(t *testing.T) { }) } +func TestNodeNextjs(t *testing.T) { + testCases := []struct { + name string + files map[string]string + wantNext bool + wantBuild string + wantWeb string + }{ + { + name: "next in dependencies with npm", + files: map[string]string{ + "package.json": `{"name":"app","dependencies":{"next":"14.0.0","react":"^18.0.0"},"scripts":{"build":"next build","start":"next start"}}`, + "package-lock.json": "{}", + }, + wantNext: true, + wantBuild: "npm run build", + wantWeb: "npx next start -p $PORT", + }, + { + name: "next with yarn", + files: map[string]string{ + "package.json": `{"name":"app","dependencies":{"next":"14.0.0"},"scripts":{"build":"next build","start":"next start"}}`, + "yarn.lock": "{}", + }, + wantNext: true, + wantBuild: "yarn build", + wantWeb: "yarn next start -p $PORT", + }, + { + name: "next listed under devDependencies", + files: map[string]string{ + "package.json": `{"name":"app","devDependencies":{"next":"14.0.0"},"scripts":{"build":"next build"}}`, + "package-lock.json": "{}", + }, + wantNext: true, + wantBuild: "npm run build", + wantWeb: "npx next start -p $PORT", + }, + { + name: "plain express app is not next", + files: map[string]string{ + "package.json": `{"name":"app","dependencies":{"express":"^4.18.2"},"scripts":{"start":"node index.js"}}`, + "package-lock.json": "{}", + }, + wantNext: false, + wantBuild: "", + wantWeb: "npm run start", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + for name, content := range tc.files { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) + } + + stack := &NodeStack{MetaStack: MetaStack{dir: dir}} + require.True(t, stack.Detect()) + stack.Init(BuildOptions{}) + + require.Equal(t, tc.wantNext, stack.hasNext) + require.Equal(t, tc.wantBuild, stack.frameworkBuildCommand()) + require.Equal(t, tc.wantWeb, stack.WebCommand()) + + // The build graph must construct and marshal cleanly, including the + // Next.js build step (the AddEnv loop + build.Run) for the Next + // cases. This covers the GenerateLLB wiring without needing Docker. + state, err := stack.GenerateLLB(dir, BuildOptions{EnvVars: map[string]string{"NEXT_PUBLIC_FOO": "bar"}}) + require.NoError(t, err) + _, err = state.Marshal(context.Background()) + require.NoError(t, err) + }) + } +} + func TestBun(t *testing.T) { if !checkDocker() { t.Skip("Docker not available")