Repository: supabase/cli
Pull request #1095 (2023-05-14 08:05:22)

feat: support deploying functions that imports from parent directory
## What kind of change does this PR introduce?

feature
closes https://github.com/supabase/cli/issues/1028

## What is the new behavior?

- pass in absolute paths to build.ts
- also drops json bundle support

## Additional context

Add any other context or screenshots.


coveralls: ## Pull Request Test Coverage Report for [Build 4971386241](https://coveralls.io/builds/59925197)

* **39** of **52**   **(75.0%)**  changed or added relevant lines in **3** files are covered.
* **1** unchanged line in **1** file lost coverage.
* Overall coverage increased (+**0.1%**) to **62.876%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [internal/utils/deno.go](https://coveralls.io/builds/59925197/source?filename=internal%2Futils%2Fdeno.go#L327) | 13 | 19 | 68.42%
| [internal/functions/deploy/deploy.go](https://coveralls.io/builds/59925197/source?filename=internal%2Ffunctions%2Fdeploy%2Fdeploy.go#L37) | 24 | 31 | 77.42%
<!-- | **Total:** | **39** | **52** | **75.0%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [internal/functions/deploy/deploy.go](https://coveralls.io/builds/59925197/source?filename=internal%2Ffunctions%2Fdeploy%2Fdeploy.go#L38) | 1 | 90.7% |
<!-- | **Total:** | **1** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/59925197/badge)](https://coveralls.io/builds/59925197) |
| :-- | --: |
| Change from base [Build 4959031100](https://coveralls.io/builds/59898451): |  0.1% |
| Covered Lines: | 4373 |
| Relevant Lines: | 6955 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/cmd/functions.go b/cmd/functions.go
index 4e6fb3ba..0de3a2ab 100644
--- a/cmd/functions.go
+++ b/cmd/functions.go
@@ -57,7 +57,7 @@ var (
 			if !cmd.Flags().Changed("no-verify-jwt") {
 				noVerifyJWT = nil
 			}
-			return deploy.Run(cmd.Context(), args[0], flags.ProjectRef, noVerifyJWT, useLegacyBundle, importMapPath, afero.NewOsFs())
+			return deploy.Run(cmd.Context(), args[0], flags.ProjectRef, noVerifyJWT, false, importMapPath, afero.NewOsFs())
 		},
 	}
 
@@ -106,6 +106,7 @@ func init() {
 	functionsDeployCmd.Flags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
 	functionsDeployCmd.Flags().BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
 	functionsDeployCmd.Flags().StringVar(&importMapPath, "import-map", "", "Path to import map file.")
+	cobra.CheckErr(functionsDeployCmd.Flags().MarkHidden("legacy-bundle"))
 	functionsServeCmd.Flags().BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.")
 	functionsServeCmd.Flags().StringVar(&envFilePath, "env-file", "", "Path to an env file to be populated to the Function environment.")
 	functionsServeCmd.Flags().StringVar(&importMapPath, "import-map", "", "Path to import map file.")
diff --git a/internal/functions/deploy/deploy.go b/internal/functions/deploy/deploy.go
index c6b192a4..0e890b34 100644
--- a/internal/functions/deploy/deploy.go
+++ b/internal/functions/deploy/deploy.go
@@ -7,7 +7,6 @@ import (
 	"fmt"
 	"io"
 	"net/http"
-	"os"
 	"os/exec"
 	"path/filepath"
 	"strings"
@@ -22,7 +21,6 @@ const eszipContentType = "application/vnd.denoland.eszip"
 
 func Run(ctx context.Context, slug string, projectRef string, noVerifyJWT *bool, useLegacyBundle bool, importMapPath string, fsys afero.Fs) error {
 	// 1. Sanity checks.
-	var scriptDir *utils.DenoScriptDir
 	{
 		// Load function config if any for fallbacks for some flags, but continue on error.
 		_ = utils.LoadConfigFS(fsys)
@@ -34,78 +32,57 @@ func Run(ctx context.Context, slug string, projectRef string, noVerifyJWT *bool,
 			}
 			noVerifyJWT = &x
 		}
-		if importMapPath != "" {
-			// skip
-		} else if functionConfig, ok := utils.Config.Functions[slug]; ok && functionConfig.ImportMap != "" {
-			if filepath.IsAbs(functionConfig.ImportMap) {
-				importMapPath = functionConfig.ImportMap
-			} else {
-				importMapPath = filepath.Join(utils.SupabaseDirPath, functionConfig.ImportMap)
-			}
-		} else if f, err := fsys.Stat(utils.FallbackImportMapPath); err == nil && !f.IsDir() {
-			importMapPath = utils.FallbackImportMapPath
-		}
-		if importMapPath != "" {
-			if _, err := fsys.Stat(importMapPath); err != nil {
-				return fmt.Errorf("Failed to read import map: %w", err)
-			}
+		resolved, err := utils.AbsImportMapPath(importMapPath, slug, fsys)
+		if err != nil {
+			return err
 		}
+		importMapPath = resolved
 		if err := utils.ValidateFunctionSlug(slug); err != nil {
 			return err
 		}
 		if err := utils.InstallOrUpgradeDeno(ctx, fsys); err != nil {
 			return err
 		}
-
-		var err error
-		scriptDir, err = utils.CopyDenoScripts(ctx, fsys)
-		if err != nil {
-			return err
-		}
 	}
 
 	// 2. Bundle Function.
-	var functionBody io.Reader
-	var functionSize int
-	{
-		fmt.Println("Bundling " + utils.Bold(slug))
-		denoPath, err := utils.GetDenoPath()
-		if err != nil {
-			return err
-		}
-
-		functionPath := filepath.Join(utils.FunctionsDir, slug)
-		if _, err := fsys.Stat(functionPath); errors.Is(err, os.ErrNotExist) {
-			// allow deploy from within supabase/
-			functionPath = filepath.Join("functions", slug)
-			if _, err := fsys.Stat(functionPath); errors.Is(err, os.ErrNotExist) {
-				// allow deploy from current directory
-				functionPath = slug
-			}
-		}
-
-		buildScriptPath := scriptDir.BuildPath
-		args := []string{"run", "-A", buildScriptPath, filepath.Join(functionPath, "index.ts"), importMapPath}
-		if useLegacyBundle {
-			args = []string{"bundle", "--no-check=remote", "--quiet", filepath.Join(functionPath, "index.ts")}
-		}
-		cmd := exec.CommandContext(ctx, denoPath, args...)
-		var outBuf, errBuf bytes.Buffer
-		cmd.Stdout = &outBuf
-		cmd.Stderr = &errBuf
-		if err := cmd.Run(); err != nil {
-			return fmt.Errorf("Error bundling function: %w\n%v", err, errBuf.String())
-		}
-
-		functionBody = &outBuf
-		functionSize = outBuf.Len()
+	scriptDir, err := utils.CopyDenoScripts(ctx, fsys)
+	if err != nil {
+		return err
+	}
+	functionBody, err := bundleFunction(ctx, slug, importMapPath, scriptDir.BuildPath, fsys)
+	if err != nil {
+		return err
 	}
+	functionSize := functionBody.Len()
 
 	// 3. Deploy new Function.
 	fmt.Println("Deploying " + utils.Bold(slug) + " (script size: " + utils.Bold(units.HumanSize(float64(functionSize))) + ")")
 	return deployFunction(ctx, projectRef, slug, functionBody, !*noVerifyJWT, useLegacyBundle)
 }
 
+func bundleFunction(ctx context.Context, slug, importMapPath, buildScriptPath string, fsys afero.Fs) (*bytes.Buffer, error) {
+	fmt.Println("Bundling " + utils.Bold(slug))
+	denoPath, err := utils.GetDenoPath()
+	if err != nil {
+		return nil, err
+	}
+	entrypointPath, err := filepath.Abs(filepath.Join(utils.FunctionsDir, slug, "index.ts"))
+	if err != nil {
+		return nil, err
+	}
+	// Bundle function and import_map with deno
+	args := []string{"run", "-A", buildScriptPath, entrypointPath, importMapPath}
+	cmd := exec.CommandContext(ctx, denoPath, args...)
+	var outBuf, errBuf bytes.Buffer
+	cmd.Stdout = &outBuf
+	cmd.Stderr = &errBuf
+	if err := cmd.Run(); err != nil {
+		return nil, fmt.Errorf("Error bundling function: %w\n%v", err, errBuf.String())
+	}
+	return &outBuf, nil
+}
+
 func makeLegacyFunctionBody(functionBody io.Reader) (string, error) {
 	buf := new(strings.Builder)
 	_, err := io.Copy(buf, functionBody)
@@ -116,6 +93,7 @@ func makeLegacyFunctionBody(functionBody io.Reader) (string, error) {
 	return buf.String(), nil
 }
 
+// TODO: Remove `useLegacyBundle` after 2023-06-01
 func deployFunction(ctx context.Context, projectRef, slug string, functionBody io.Reader, verifyJWT, useLegacyBundle bool) error {
 	{
 		resp, err := utils.GetSupabase().GetFunctionWithResponse(ctx, projectRef, slug)
diff --git a/internal/functions/serve/serve.go b/internal/functions/serve/serve.go
index e15f3cec..b5d964dd 100644
--- a/internal/functions/serve/serve.go
+++ b/internal/functions/serve/serve.go
@@ -55,32 +55,11 @@ func Run(ctx context.Context, slug string, envFilePath string, noVerifyJWT *bool
 				return fmt.Errorf("Failed to read env file: %w", err)
 			}
 		}
-		cwd, err := os.Getwd()
+		resolved, err := utils.AbsImportMapPath(importMapPath, slug, fsys)
 		if err != nil {
 			return err
 		}
-		if importMapPath != "" {
-			if !filepath.IsAbs(importMapPath) {
-				importMapPath = filepath.Join(cwd, importMapPath)
-			}
-		} else if functionConfig, ok := utils.Config.Functions[slug]; ok && functionConfig.ImportMap != "" {
-			if filepath.IsAbs(functionConfig.ImportMap) {
-				importMapPath = functionConfig.ImportMap
-			} else {
-				importMapPath = filepath.Join(cwd, utils.SupabaseDirPath, functionConfig.ImportMap)
-			}
-		} else if f, err := fsys.Stat(utils.FallbackImportMapPath); err == nil && !f.IsDir() {
-			if filepath.IsAbs(utils.FallbackImportMapPath) {
-				importMapPath = utils.FallbackImportMapPath
-			} else {
-				importMapPath = filepath.Join(cwd, utils.FallbackImportMapPath)
-			}
-		}
-		if importMapPath != "" {
-			if _, err := fsys.Stat(importMapPath); err != nil {
-				return fmt.Errorf("Failed to read import map: %w", err)
-			}
-		}
+		importMapPath = resolved
 	}
 
 	// 2. Parse user defined env
diff --git a/internal/utils/deno.go b/internal/utils/deno.go
index 105748fe..1bd3582f 100644
--- a/internal/utils/deno.go
+++ b/internal/utils/deno.go
@@ -320,3 +320,28 @@ func GetPathHash(path string) string {
 	digest := sha256.Sum256([]byte(path))
 	return hex.EncodeToString(digest[:])
 }
+
+func AbsImportMapPath(importMapPath, slug string, fsys afero.Fs) (string, error) {
+	if importMapPath == "" {
+		if functionConfig, ok := Config.Functions[slug]; ok && functionConfig.ImportMap != "" {
+			importMapPath = functionConfig.ImportMap
+			if !filepath.IsAbs(importMapPath) {
+				importMapPath = filepath.Join(SupabaseDirPath, importMapPath)
+			}
+		} else if exists, _ := afero.Exists(fsys, FallbackImportMapPath); exists {
+			importMapPath = FallbackImportMapPath
+		} else {
+			return importMapPath, nil
+		}
+	}
+	resolved, err := filepath.Abs(importMapPath)
+	if err != nil {
+		return "", err
+	}
+	if f, err := fsys.Stat(resolved); err != nil {
+		return "", fmt.Errorf("Failed to read import map: %w", err)
+	} else if f.IsDir() {
+		return "", errors.New("Importing directory is unsupported: " + resolved)
+	}
+	return resolved, nil
+}
diff --git a/internal/utils/denos/build.ts b/internal/utils/denos/build.ts
index 322e7508..dc241172 100644
--- a/internal/utils/denos/build.ts
+++ b/internal/utils/denos/build.ts
@@ -1,40 +1,19 @@
 import { encode } from "https://deno.land/std@0.127.0/encoding/base64.ts";
-import * as path from "https://deno.land/std@0.127.0/path/mod.ts";
 import { writeAll } from "https://deno.land/std@0.162.0/streams/conversion.ts";
 import { compress } from "https://deno.land/x/brotli@0.1.7/mod.ts";
 import { build } from "https://deno.land/x/eszip@v0.35.0/mod.ts";
 
-const virtualBasePath = "file:///src/";
-
 async function buildAndWrite(p: string, importMapPath: string) {
-  const funcDirPath = path.dirname(p);
-  try {
-    await Deno.lstat(funcDirPath);
-  } catch (e) {
-    console.error(
-      `Error: Cannot access "${funcDirPath}". Check if directory exists and has read permissions.`,
-    );
-    Deno.exit(1);
-  }
-
-  const entrypoint = new URL("index.ts", virtualBasePath).href;
+  const cwd = `file://${Deno.cwd()}/`
+  const entrypoint = new URL(p, cwd).href;
+  const importMap = new URL(importMapPath || "supabase/functions/import_map.json", cwd).href
 
   const eszip = await build([entrypoint], async (specifier: string) => {
     const url = new URL(specifier);
     if (url.protocol === "file:") {
       console.error(specifier);
-      // if the path is `file:///*`, treat it as a path from parent directory
-      let actualPath = specifier.replace("file:///", `./${funcDirPath}/../`);
-      // if the path is `file:///src/*`, treat it as a relative path from current dir
-      if (specifier.startsWith(virtualBasePath)) {
-        actualPath = specifier.replace(virtualBasePath, `./${funcDirPath}/`);
-      }
+      const actualPath = url.pathname;
 
-      // If an import map path is set read file from the given path.
-      // Otherwise default to `import_map.json` in functions directory.
-      if (specifier.endsWith("import_map.json") && importMapPath) {
-        actualPath = importMapPath;
-      }
       try {
         const content = await Deno.readTextFile(actualPath);
         return {
@@ -60,7 +39,7 @@ async function buildAndWrite(p: string, importMapPath: string) {
     }
 
     return load(specifier);
-  }, "file:///src/import_map.json");
+  }, importMap);
   // compress ESZIP payload using Brotli
   const compressed = compress(eszip);
 


Test patch:
diff --git a/internal/functions/deploy/deploy_test.go b/internal/functions/deploy/deploy_test.go
index 88fefb42..739e470b 100644
--- a/internal/functions/deploy/deploy_test.go
+++ b/internal/functions/deploy/deploy_test.go
@@ -11,6 +11,7 @@ import (
 	"log"
 	"net/http"
 	"os"
+	"path/filepath"
 	"strings"
 	"testing"
 
@@ -340,14 +341,17 @@ verify_jwt = false
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
 		require.NoError(t, utils.WriteConfig(fsys, false))
-		require.NoError(t, afero.WriteFile(fsys, "supabase/functions/import_map.json", []byte(""), 0644))
+		require.NoError(t, afero.WriteFile(fsys, utils.FallbackImportMapPath, []byte(""), 0644))
+		absPath, err := filepath.Abs(utils.FallbackImportMapPath)
+		require.NoError(t, err)
+		require.NoError(t, afero.WriteFile(fsys, absPath, []byte("{}"), 0644))
 		// Setup valid project ref
 		project := apitest.RandomProjectRef()
 		// Setup valid access token
 		token := apitest.RandomAccessToken(t)
 		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
 		// Setup valid deno path
-		_, err := fsys.Create(utils.DenoPathOverride)
+		_, err = fsys.Create(utils.DenoPathOverride)
 		require.NoError(t, err)
 		// Setup mock api
 		defer gock.OffAll()
diff --git a/internal/utils/deno_test.go b/internal/utils/deno_test.go
index c6332be4..9a5840bf 100644
--- a/internal/utils/deno_test.go
+++ b/internal/utils/deno_test.go
@@ -54,3 +54,70 @@ func TestResolveImports(t *testing.T) {
 		assert.Equal(t, "https://deno.land", resolved.Scopes["my-scope"]["my-mod"])
 	})
 }
+
+func TestImportMapPath(t *testing.T) {
+	t.Run("loads import map from default location", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		require.NoError(t, afero.WriteFile(fsys, FallbackImportMapPath, []byte("{}"), 0644))
+		absPath, err := filepath.Abs(FallbackImportMapPath)
+		require.NoError(t, err)
+		require.NoError(t, afero.WriteFile(fsys, absPath, []byte("{}"), 0644))
+		// Run test
+		resolved, err := AbsImportMapPath("", "", fsys)
+		// Check error
+		assert.NoError(t, err)
+		assert.Equal(t, absPath, resolved)
+	})
+
+	t.Run("per function config takes precedence", func(t *testing.T) {
+		slug := "hello"
+		Config.Functions = map[string]function{
+			slug: {ImportMap: "import_map.json"},
+		}
+		absPath, err := filepath.Abs("supabase/import_map.json")
+		require.NoError(t, err)
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		require.NoError(t, afero.WriteFile(fsys, FallbackImportMapPath, []byte("{}"), 0644))
+		require.NoError(t, afero.WriteFile(fsys, absPath, []byte("{}"), 0644))
+		// Run test
+		resolved, err := AbsImportMapPath("", slug, fsys)
+		// Check error
+		assert.NoError(t, err)
+		assert.Equal(t, absPath, resolved)
+	})
+
+	t.Run("returns empty string if no fallback", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Run test
+		resolved, err := AbsImportMapPath("", "", fsys)
+		// Check error
+		assert.NoError(t, err)
+		assert.Empty(t, resolved)
+	})
+
+	t.Run("throws error on missing file", func(t *testing.T) {
+		path := "/tmp/import_map"
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Run test
+		resolved, err := AbsImportMapPath(path, "", fsys)
+		// Check error
+		assert.ErrorIs(t, err, os.ErrNotExist)
+		assert.Empty(t, resolved)
+	})
+
+	t.Run("throws error on importing directory", func(t *testing.T) {
+		path := "/tmp/import_map"
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		require.NoError(t, fsys.MkdirAll(path, 0755))
+		// Run test
+		resolved, err := AbsImportMapPath(path, "", fsys)
+		// Check error
+		assert.ErrorContains(t, err, "Importing directory is unsupported: "+path)
+		assert.Empty(t, resolved)
+	})
+}