Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3eedb2c
feat: enable serverside unbundling
johnstonmatt Nov 3, 2025
c7e7c7e
chore: use a single flag for api deploy
sweatybridge Nov 4, 2025
e7173ba
chore: hide use docker flags
sweatybridge Nov 4, 2025
6d647db
chore: remove unnecessary calls
sweatybridge Nov 4, 2025
e79c831
test: ensure ""./../.." fails to joinWithinDir
johnstonmatt Nov 4, 2025
b235fb2
Merge branch 'develop' into DEVWF-756/server-side-unbundle
johnstonmatt Nov 5, 2025
5f77ec2
wip: server-side unbundle: entrypoint-based directory structuring
johnstonmatt Nov 6, 2025
6677afd
Merge branch 'DEVWF-756/server-side-unbundle' of https://github.com/s…
johnstonmatt Nov 6, 2025
fb4483c
Merge branch 'develop' of https://github.com/supabase/cli into DEVWF-…
johnstonmatt Nov 6, 2025
f28b2c3
test: better --use-docker coverage
johnstonmatt Nov 6, 2025
668e3e9
refactor: improve coverage of "supabase functions download --use-api"
johnstonmatt Nov 10, 2025
0244594
test: better coverage for abs paths from getBaseDir
johnstonmatt Nov 10, 2025
b8b5084
fix: lintfix cleanup tmp files
johnstonmatt Nov 10, 2025
bdfc71a
Merge branch 'develop' into DEVWF-756/server-side-unbundle
johnstonmatt Nov 10, 2025
9ccfda6
Merge branch 'develop' of https://github.com/supabase/cli into DEVWF-…
johnstonmatt Nov 10, 2025
f08a18d
Merge branch 'DEVWF-756/server-side-unbundle' of https://github.com/s…
johnstonmatt Nov 10, 2025
7246f62
refactor: limit changes to existing tests
johnstonmatt Nov 10, 2025
0b17344
test: rename TestRun and delete bad comment
johnstonmatt Nov 10, 2025
f4cb36b
docs: remove big comment
johnstonmatt Nov 10, 2025
d8497f1
refactor: remove extraneous const
johnstonmatt Nov 10, 2025
5c62d01
test: unify test implementations across api, docker, legacy
johnstonmatt Nov 10, 2025
d07722c
refactor: improve cleanup func name and comment
johnstonmatt Nov 10, 2025
a939020
fix: account for deno2 entrypoint path
sweatybridge Nov 13, 2025
2c9bc27
chore: cleanup unit tests
sweatybridge Nov 13, 2025
897ea3f
chore: address linter warnings
sweatybridge Nov 13, 2025
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
16 changes: 13 additions & 3 deletions cmd/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ var (
Long: "Download the source code for a Function from the linked Supabase project.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return download.Run(cmd.Context(), args[0], flags.ProjectRef, useLegacyBundle, afero.NewOsFs())
if useApi {
useDocker = false
}
return download.Run(cmd.Context(), args[0], flags.ProjectRef, useLegacyBundle, useDocker, afero.NewOsFs())
},
}

Expand Down Expand Up @@ -138,6 +141,7 @@ func init() {
deployFlags.BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
functionsDeployCmd.MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle")
cobra.CheckErr(deployFlags.MarkHidden("legacy-bundle"))
cobra.CheckErr(deployFlags.MarkHidden("use-docker"))
deployFlags.UintVarP(&maxJobs, "jobs", "j", 1, "Maximum number of parallel jobs.")
deployFlags.BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.")
deployFlags.BoolVar(&prune, "prune", false, "Delete Functions that exist in Supabase project but not locally.")
Expand All @@ -152,8 +156,14 @@ func init() {
functionsServeCmd.MarkFlagsMutuallyExclusive("inspect", "inspect-mode")
functionsServeCmd.Flags().Bool("all", true, "Serve all Functions.")
cobra.CheckErr(functionsServeCmd.Flags().MarkHidden("all"))
functionsDownloadCmd.Flags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
functionsDownloadCmd.Flags().BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
downloadFlags := functionsDownloadCmd.Flags()
downloadFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
downloadFlags.BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
downloadFlags.BoolVar(&useApi, "use-api", false, "Use Management API to unbundle functions server-side.")
downloadFlags.BoolVar(&useDocker, "use-docker", true, "Use Docker to unbundle functions client-side.")
functionsDownloadCmd.MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle")
cobra.CheckErr(downloadFlags.MarkHidden("legacy-bundle"))
cobra.CheckErr(downloadFlags.MarkHidden("use-docker"))
functionsCmd.AddCommand(functionsListCmd)
functionsCmd.AddCommand(functionsDeleteCmd)
functionsCmd.AddCommand(functionsDeployCmd)
Expand Down
159 changes: 154 additions & 5 deletions internal/functions/download/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"context"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"os/exec"
Expand Down Expand Up @@ -112,15 +114,30 @@ func downloadFunction(ctx context.Context, projectRef, slug, extractScriptPath s
return nil
}

func Run(ctx context.Context, slug string, projectRef string, useLegacyBundle bool, fsys afero.Fs) error {
func Run(ctx context.Context, slug, projectRef string, useLegacyBundle, useDocker bool, fsys afero.Fs) error {
// Sanity check
if err := flags.LoadConfig(fsys); err != nil {
return err
}

if useLegacyBundle {
return RunLegacy(ctx, slug, projectRef, fsys)
}
// 1. Sanity check
if err := flags.LoadConfig(fsys); err != nil {
return err

if useDocker {
if utils.IsDockerRunning(ctx) {
// download eszip file for client-side unbundling with edge-runtime
return downloadWithDockerUnbundle(ctx, slug, projectRef, fsys)
} else {
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "Docker is not running")
}
}
// 2. Download eszip to temp file

// Use server-side unbundling with multipart/form-data
return downloadWithServerSideUnbundle(ctx, slug, projectRef, fsys)
}

func downloadWithDockerUnbundle(ctx context.Context, slug string, projectRef string, fsys afero.Fs) error {
eszipPath, err := downloadOne(ctx, slug, projectRef, fsys)
if err != nil {
return err
Expand Down Expand Up @@ -238,3 +255,135 @@ deno_version = 2
func suggestLegacyBundle(slug string) string {
return fmt.Sprintf("\nIf your function is deployed using CLI < 1.120.0, trying running %s instead.", utils.Aqua("supabase functions download --legacy-bundle "+slug))
}

func downloadWithServerSideUnbundle(ctx context.Context, slug, projectRef string, fsys afero.Fs) error {
fmt.Fprintln(os.Stderr, "Downloading "+utils.Bold(slug))

// Request multipart/form-data response using RequestEditorFn
resp, err := utils.GetSupabase().V1GetAFunctionBody(ctx, projectRef, slug, func(ctx context.Context, req *http.Request) error {
req.Header.Set("Accept", "multipart/form-data")
return nil
})
if err != nil {
return errors.Errorf("failed to download function: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Errorf("Error status %d: %w", resp.StatusCode, err)
}
return errors.Errorf("Error status %d: %s", resp.StatusCode, string(body))
}

// Parse the multipart response
mediaType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return errors.Errorf("failed to parse content type: %w", err)
}

if !strings.HasPrefix(mediaType, "multipart/") {
return errors.Errorf("expected multipart response, got %s", mediaType)
}

// Create function directory
funcDir := filepath.Join(utils.FunctionsDir, slug)

if err := utils.MkdirIfNotExistFS(fsys, funcDir); err != nil {
return err
}

// Parse multipart form
mr := multipart.NewReader(resp.Body, params["boundary"])
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return errors.Errorf("failed to read multipart: %w", err)
}

// Determine the relative path from headers to preserve directory structure.
relPath, err := resolvedPartPath(slug, part) // always starts with :slug
if err != nil {
return err
}

// result of invalid or missing filename but we're letting it slide
if relPath == "" {
fmt.Fprintln(utils.GetDebugLogger(), "Skipping part without filename")
continue
}

filePath, err := joinWithinDir(funcDir, relPath)
if err != nil {
return err
}

if err := afero.WriteReader(fsys, filePath, part); err != nil {
return errors.Errorf("failed to write file: %w", err)
}
}

fmt.Println("Downloaded Function " + utils.Aqua(slug) + " from project " + utils.Aqua(projectRef) + ".")
return nil
}

// parse multipart part headers to read and sanitize relative file path for writing
func resolvedPartPath(slug string, part *multipart.Part) (string, error) {
// dedicated header to specify relative path, not expected to be used
if relPath := part.Header.Get("Supabase-Path"); relPath != "" {
return normalizeRelativePath(slug, relPath), nil
}

// part.FileName() does not allow us to handle relative paths, so we parse Content-Disposition manually
cd := part.Header.Get("Content-Disposition")
if cd == "" {
return "", nil
}

_, params, err := mime.ParseMediaType(cd)
if err != nil {
return "", errors.Errorf("failed to parse content disposition: %w", err)
}

if filename := params["filename"]; filename != "" {
return normalizeRelativePath(slug, filename), nil
}
return "", nil
}

// remove leading source/ or :slug/
func normalizeRelativePath(slug, raw string) string {
cleaned := path.Clean(raw)
if after, ok := strings.CutPrefix(cleaned, "source/"); ok {
cleaned = after
} else if after, ok := strings.CutPrefix(cleaned, slug+"/"); ok {
cleaned = after
} else if cleaned == slug {
// If the path is exactly :slug, skip it
cleaned = ""
}
return cleaned
}

// joinWithinDir safely joins base and rel ensuring the result stays within base directory
func joinWithinDir(base, rel string) (string, error) {
cleanRel := filepath.Clean(rel)
// Be forgiving: treat a rooted path as relative to base (e.g. "/foo" -> "foo")
if filepath.IsAbs(cleanRel) {
cleanRel = strings.TrimLeft(cleanRel, "/\\")
}
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") {
return "", errors.Errorf("invalid file path outside function directory: %s", rel)
}
joined := filepath.Join(base, cleanRel)
cleanJoined := filepath.Clean(joined)
cleanBase := filepath.Clean(base)
if cleanJoined != cleanBase && !strings.HasPrefix(cleanJoined, cleanBase+"/") {
return "", errors.Errorf("refusing to write outside function directory: %s", rel)
}
return joined, nil
}
Loading