From c889de2c32a469c6f99697d89c142e945ff684df Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Fri, 24 Jul 2026 20:14:45 -0700 Subject: [PATCH 1/3] stackbuild: detect Next.js and run its production build The Node stack installed dependencies and copied the app, but never ran a build step, so a Next.js app shipped without a compiled `.next/` directory and failed at runtime when served with `next start`. Follow the pattern the Ruby stack already uses for Rails: detect the framework during Init (a `next` dependency), run its build in the image (`next build`, via the app's build script) after the source is copied, and serve it with `next start` bound to $PORT. User env vars are injected during the build so build-time values like NEXT_PUBLIC_* are inlined, the same way the Ruby stack injects env for asset precompilation. Scoped to the default Next.js output; standalone and static-export modes are follow-ups. Plain Node apps are unaffected (no build step, unchanged web command). Part of MIR-1462 --- pkg/stackbuild/node.go | 51 +++++++++++++++++++++++ pkg/stackbuild/stackbuild_test.go | 68 +++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/pkg/stackbuild/node.go b/pkg/stackbuild/node.go index 57f2da463..7f2ca7558 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,11 @@ func (s *NodeStack) parsePackageJSON() { } func (s *NodeStack) WebCommand() string { + // Next.js is served with `next start`; bind to the platform-provided port. + if s.hasNext { + 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..9f41543eb 100644 --- a/pkg/stackbuild/stackbuild_test.go +++ b/pkg/stackbuild/stackbuild_test.go @@ -405,6 +405,74 @@ 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: "npx 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()) + }) + } +} + func TestBun(t *testing.T) { if !checkDocker() { t.Skip("Docker not available") From 04d2d8620f8ba9ac21c963ed0da823942cabfdee Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Fri, 24 Jul 2026 20:21:46 -0700 Subject: [PATCH 2/3] fix CI: use package-manager-aware next start command biscuit review flagged that WebCommand returned `npx next start` even for yarn projects, while frameworkBuildCommand already picks yarn vs npm. Make the serve command follow the detected package manager too. Authored-by: Claude Code (autonomous agent) --- pkg/stackbuild/node.go | 5 +++++ pkg/stackbuild/stackbuild_test.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/stackbuild/node.go b/pkg/stackbuild/node.go index 7f2ca7558..35dc95803 100644 --- a/pkg/stackbuild/node.go +++ b/pkg/stackbuild/node.go @@ -276,7 +276,12 @@ 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" } diff --git a/pkg/stackbuild/stackbuild_test.go b/pkg/stackbuild/stackbuild_test.go index 9f41543eb..f6ea9040d 100644 --- a/pkg/stackbuild/stackbuild_test.go +++ b/pkg/stackbuild/stackbuild_test.go @@ -431,7 +431,7 @@ func TestNodeNextjs(t *testing.T) { }, wantNext: true, wantBuild: "yarn build", - wantWeb: "npx next start -p $PORT", + wantWeb: "yarn next start -p $PORT", }, { name: "next listed under devDependencies", From e37d9d1b329e0e3a55af8dd0536dfa78b2ed72a6 Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Fri, 24 Jul 2026 20:37:40 -0700 Subject: [PATCH 3/3] test: cover Next.js build-step wiring via GenerateLLB marshal Addresses biscuit's second review concern: TestNodeNextjs exercised detection and the command strings but never called GenerateLLB, leaving the Next.js build step (env injection + build.Run) unverified. Construct and marshal the graph so ordering/env-injection mistakes are caught without needing Docker. Authored-by: Claude Code (autonomous agent) --- pkg/stackbuild/stackbuild_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/stackbuild/stackbuild_test.go b/pkg/stackbuild/stackbuild_test.go index f6ea9040d..963842aa5 100644 --- a/pkg/stackbuild/stackbuild_test.go +++ b/pkg/stackbuild/stackbuild_test.go @@ -469,6 +469,14 @@ func TestNodeNextjs(t *testing.T) { 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) }) } }