Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
182 changes: 177 additions & 5 deletions internal/functions/download/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"os/exec"
"path"
Expand All @@ -16,6 +21,7 @@ import (
"github.com/andybalholm/brotli"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/go-units"
"github.com/go-errors/errors"
"github.com/spf13/afero"
"github.com/spf13/viper"
Expand Down Expand Up @@ -112,15 +118,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 +259,154 @@ 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))
}

type bundleMetadata struct {
EntrypointPath string `json:"deno2_entrypoint_path,omitempty"`
}

// New server-side unbundle implementation that mirrors Studio's entrypoint-based
// base-dir + relative path behaviour.
func downloadWithServerSideUnbundle(ctx context.Context, slug, projectRef string, fsys afero.Fs) error {
fmt.Fprintln(os.Stderr, "Downloading Function:", utils.Bold(slug))

form, err := readForm(ctx, projectRef, slug)
if err != nil {
return err
}
defer func() {
if err := form.RemoveAll(); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}()

// Read entrypoint path from deno2 bundles
metadata := bundleMetadata{}
if data, ok := form.Value["metadata"]; ok {
for _, part := range data {
if err := json.Unmarshal([]byte(part), &metadata); err != nil {
return errors.Errorf("failed to unmarshal metadata: %w", err)
}
}
}

// Fallback to function metadata from upstash
if len(metadata.EntrypointPath) == 0 {
upstash, err := getFunctionMetadata(ctx, projectRef, slug)
if err != nil {
return errors.Errorf("failed to get function metadata: %w", err)
}
entrypointUrl, err := url.Parse(*upstash.EntrypointPath)
if err != nil {
return errors.Errorf("failed to parse entrypoint URL: %w", err)
}
metadata.EntrypointPath = entrypointUrl.Path
}
fmt.Fprintln(utils.GetDebugLogger(), "Using entrypoint path:", metadata.EntrypointPath)

// Root directory on disk: supabase/functions/<slug>
funcDir := filepath.Join(utils.FunctionsDir, slug)
for _, data := range form.File {
for _, file := range data {
if err := saveFile(file, metadata.EntrypointPath, funcDir, fsys); err != nil {
return err
}
}
}

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

func readForm(ctx context.Context, projectRef, slug string) (*multipart.Form, error) {
// 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 nil, 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 nil, errors.Errorf("Error status %d: %w", resp.StatusCode, err)
}
return nil, 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 nil, errors.Errorf("failed to parse content type: %w", err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
return nil, errors.Errorf("expected multipart response, got %s", mediaType)
}

// Read entire response with caching to disk
mr := multipart.NewReader(resp.Body, params["boundary"])
form, err := mr.ReadForm(units.MiB)
if err != nil {
return nil, errors.Errorf("failed to read form: %w", err)
}

return form, nil
}

func saveFile(file *multipart.FileHeader, entrypointPath, funcDir string, fsys afero.Fs) error {
part, err := file.Open()
if err != nil {
return errors.Errorf("failed to open file: %w", err)
}
defer part.Close()

logger := utils.GetDebugLogger()
partPath, err := getPartPath(file.Header)
if len(partPath) == 0 {
fmt.Fprintln(logger, "Skipping file with empty path:", file.Filename)
return err
}
fmt.Fprintln(logger, "Resolving file path:", partPath)

relPath, err := filepath.Rel(filepath.FromSlash(entrypointPath), filepath.FromSlash(partPath))
if err != nil {
// Continue extracting without entrypoint
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), err)
relPath = filepath.FromSlash(path.Join("..", partPath))
}

dstPath := filepath.Join(funcDir, path.Base(entrypointPath), relPath)
fmt.Fprintln(os.Stderr, "Extracting file:", dstPath)
if err := afero.WriteReader(fsys, dstPath, part); err != nil {
return errors.Errorf("failed to save file: %w", err)
}

return nil
}

// getPartPath extracts the filename for a multipart part, allowing for
// relative paths via the custom Supabase-Path header.
func getPartPath(header textproto.MIMEHeader) (string, error) {
// dedicated header to specify relative path, not expected to be used
if relPath := header.Get("Supabase-Path"); relPath != "" {
return relPath, nil
}

// part.FileName() does not allow us to handle relative paths, so we parse Content-Disposition manually
cd := 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 filename, nil
}
return "", nil
}
Loading