Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions pkg/stackbuild/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
76 changes: 76 additions & 0 deletions pkg/stackbuild/stackbuild_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
miren-code-agent[bot] marked this conversation as resolved.
if !checkDocker() {
t.Skip("Docker not available")
Expand Down
Loading