Skip to content

Commit cbbe7ec

Browse files
feat: functions download foo --use-api (#4381)
* feat: enable serverside unbundling * chore: use a single flag for api deploy * chore: hide use docker flags * chore: remove unnecessary calls * test: ensure ""./../.." fails to joinWithinDir * wip: server-side unbundle: entrypoint-based directory structuring * test: better --use-docker coverage * refactor: improve coverage of "supabase functions download --use-api" * test: better coverage for abs paths from getBaseDir * fix: lintfix cleanup tmp files * refactor: limit changes to existing tests * test: rename TestRun and delete bad comment * docs: remove big comment * refactor: remove extraneous const * test: unify test implementations across api, docker, legacy * refactor: improve cleanup func name and comment * fix: account for deno2 entrypoint path * chore: cleanup unit tests * chore: address linter warnings --------- Co-authored-by: Qiao Han <qiao@supabase.io>
1 parent 9f3ad13 commit cbbe7ec

File tree

3 files changed

+519
-14
lines changed

3 files changed

+519
-14
lines changed

cmd/functions.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ var (
4949
Long: "Download the source code for a Function from the linked Supabase project.",
5050
Args: cobra.ExactArgs(1),
5151
RunE: func(cmd *cobra.Command, args []string) error {
52-
return download.Run(cmd.Context(), args[0], flags.ProjectRef, useLegacyBundle, afero.NewOsFs())
52+
if useApi {
53+
useDocker = false
54+
}
55+
return download.Run(cmd.Context(), args[0], flags.ProjectRef, useLegacyBundle, useDocker, afero.NewOsFs())
5356
},
5457
}
5558

@@ -138,6 +141,7 @@ func init() {
138141
deployFlags.BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
139142
functionsDeployCmd.MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle")
140143
cobra.CheckErr(deployFlags.MarkHidden("legacy-bundle"))
144+
cobra.CheckErr(deployFlags.MarkHidden("use-docker"))
141145
deployFlags.UintVarP(&maxJobs, "jobs", "j", 1, "Maximum number of parallel jobs.")
142146
deployFlags.BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.")
143147
deployFlags.BoolVar(&prune, "prune", false, "Delete Functions that exist in Supabase project but not locally.")
@@ -152,8 +156,14 @@ func init() {
152156
functionsServeCmd.MarkFlagsMutuallyExclusive("inspect", "inspect-mode")
153157
functionsServeCmd.Flags().Bool("all", true, "Serve all Functions.")
154158
cobra.CheckErr(functionsServeCmd.Flags().MarkHidden("all"))
155-
functionsDownloadCmd.Flags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
156-
functionsDownloadCmd.Flags().BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
159+
downloadFlags := functionsDownloadCmd.Flags()
160+
downloadFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
161+
downloadFlags.BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
162+
downloadFlags.BoolVar(&useApi, "use-api", false, "Use Management API to unbundle functions server-side.")
163+
downloadFlags.BoolVar(&useDocker, "use-docker", true, "Use Docker to unbundle functions client-side.")
164+
functionsDownloadCmd.MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle")
165+
cobra.CheckErr(downloadFlags.MarkHidden("legacy-bundle"))
166+
cobra.CheckErr(downloadFlags.MarkHidden("use-docker"))
157167
functionsCmd.AddCommand(functionsListCmd)
158168
functionsCmd.AddCommand(functionsDeleteCmd)
159169
functionsCmd.AddCommand(functionsDeployCmd)

internal/functions/download/download.go

Lines changed: 177 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@ import (
44
"bufio"
55
"bytes"
66
"context"
7+
"encoding/json"
78
"fmt"
89
"io"
10+
"mime"
11+
"mime/multipart"
912
"net/http"
13+
"net/textproto"
14+
"net/url"
1015
"os"
1116
"os/exec"
1217
"path"
@@ -16,6 +21,7 @@ import (
1621
"github.com/andybalholm/brotli"
1722
"github.com/docker/docker/api/types/container"
1823
"github.com/docker/docker/api/types/network"
24+
"github.com/docker/go-units"
1925
"github.com/go-errors/errors"
2026
"github.com/spf13/afero"
2127
"github.com/spf13/viper"
@@ -112,15 +118,30 @@ func downloadFunction(ctx context.Context, projectRef, slug, extractScriptPath s
112118
return nil
113119
}
114120

115-
func Run(ctx context.Context, slug string, projectRef string, useLegacyBundle bool, fsys afero.Fs) error {
121+
func Run(ctx context.Context, slug, projectRef string, useLegacyBundle, useDocker bool, fsys afero.Fs) error {
122+
// Sanity check
123+
if err := flags.LoadConfig(fsys); err != nil {
124+
return err
125+
}
126+
116127
if useLegacyBundle {
117128
return RunLegacy(ctx, slug, projectRef, fsys)
118129
}
119-
// 1. Sanity check
120-
if err := flags.LoadConfig(fsys); err != nil {
121-
return err
130+
131+
if useDocker {
132+
if utils.IsDockerRunning(ctx) {
133+
// download eszip file for client-side unbundling with edge-runtime
134+
return downloadWithDockerUnbundle(ctx, slug, projectRef, fsys)
135+
} else {
136+
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "Docker is not running")
137+
}
122138
}
123-
// 2. Download eszip to temp file
139+
140+
// Use server-side unbundling with multipart/form-data
141+
return downloadWithServerSideUnbundle(ctx, slug, projectRef, fsys)
142+
}
143+
144+
func downloadWithDockerUnbundle(ctx context.Context, slug string, projectRef string, fsys afero.Fs) error {
124145
eszipPath, err := downloadOne(ctx, slug, projectRef, fsys)
125146
if err != nil {
126147
return err
@@ -238,3 +259,154 @@ deno_version = 2
238259
func suggestLegacyBundle(slug string) string {
239260
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))
240261
}
262+
263+
type bundleMetadata struct {
264+
EntrypointPath string `json:"deno2_entrypoint_path,omitempty"`
265+
}
266+
267+
// New server-side unbundle implementation that mirrors Studio's entrypoint-based
268+
// base-dir + relative path behaviour.
269+
func downloadWithServerSideUnbundle(ctx context.Context, slug, projectRef string, fsys afero.Fs) error {
270+
fmt.Fprintln(os.Stderr, "Downloading Function:", utils.Bold(slug))
271+
272+
form, err := readForm(ctx, projectRef, slug)
273+
if err != nil {
274+
return err
275+
}
276+
defer func() {
277+
if err := form.RemoveAll(); err != nil {
278+
fmt.Fprintln(os.Stderr, err)
279+
}
280+
}()
281+
282+
// Read entrypoint path from deno2 bundles
283+
metadata := bundleMetadata{}
284+
if data, ok := form.Value["metadata"]; ok {
285+
for _, part := range data {
286+
if err := json.Unmarshal([]byte(part), &metadata); err != nil {
287+
return errors.Errorf("failed to unmarshal metadata: %w", err)
288+
}
289+
}
290+
}
291+
292+
// Fallback to function metadata from upstash
293+
if len(metadata.EntrypointPath) == 0 {
294+
upstash, err := getFunctionMetadata(ctx, projectRef, slug)
295+
if err != nil {
296+
return errors.Errorf("failed to get function metadata: %w", err)
297+
}
298+
entrypointUrl, err := url.Parse(*upstash.EntrypointPath)
299+
if err != nil {
300+
return errors.Errorf("failed to parse entrypoint URL: %w", err)
301+
}
302+
metadata.EntrypointPath = entrypointUrl.Path
303+
}
304+
fmt.Fprintln(utils.GetDebugLogger(), "Using entrypoint path:", metadata.EntrypointPath)
305+
306+
// Root directory on disk: supabase/functions/<slug>
307+
funcDir := filepath.Join(utils.FunctionsDir, slug)
308+
for _, data := range form.File {
309+
for _, file := range data {
310+
if err := saveFile(file, metadata.EntrypointPath, funcDir, fsys); err != nil {
311+
return err
312+
}
313+
}
314+
}
315+
316+
fmt.Println("Downloaded Function " + utils.Aqua(slug) + " from project " + utils.Aqua(projectRef) + ".")
317+
return nil
318+
}
319+
320+
func readForm(ctx context.Context, projectRef, slug string) (*multipart.Form, error) {
321+
// Request multipart/form-data response using RequestEditorFn
322+
resp, err := utils.GetSupabase().V1GetAFunctionBody(ctx, projectRef, slug, func(ctx context.Context, req *http.Request) error {
323+
req.Header.Set("Accept", "multipart/form-data")
324+
return nil
325+
})
326+
if err != nil {
327+
return nil, errors.Errorf("failed to download function: %w", err)
328+
}
329+
defer resp.Body.Close()
330+
331+
if resp.StatusCode != http.StatusOK {
332+
body, err := io.ReadAll(resp.Body)
333+
if err != nil {
334+
return nil, errors.Errorf("Error status %d: %w", resp.StatusCode, err)
335+
}
336+
return nil, errors.Errorf("Error status %d: %s", resp.StatusCode, string(body))
337+
}
338+
339+
// Parse the multipart response
340+
mediaType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
341+
if err != nil {
342+
return nil, errors.Errorf("failed to parse content type: %w", err)
343+
}
344+
if !strings.HasPrefix(mediaType, "multipart/") {
345+
return nil, errors.Errorf("expected multipart response, got %s", mediaType)
346+
}
347+
348+
// Read entire response with caching to disk
349+
mr := multipart.NewReader(resp.Body, params["boundary"])
350+
form, err := mr.ReadForm(units.MiB)
351+
if err != nil {
352+
return nil, errors.Errorf("failed to read form: %w", err)
353+
}
354+
355+
return form, nil
356+
}
357+
358+
func saveFile(file *multipart.FileHeader, entrypointPath, funcDir string, fsys afero.Fs) error {
359+
part, err := file.Open()
360+
if err != nil {
361+
return errors.Errorf("failed to open file: %w", err)
362+
}
363+
defer part.Close()
364+
365+
logger := utils.GetDebugLogger()
366+
partPath, err := getPartPath(file.Header)
367+
if len(partPath) == 0 {
368+
fmt.Fprintln(logger, "Skipping file with empty path:", file.Filename)
369+
return err
370+
}
371+
fmt.Fprintln(logger, "Resolving file path:", partPath)
372+
373+
relPath, err := filepath.Rel(filepath.FromSlash(entrypointPath), filepath.FromSlash(partPath))
374+
if err != nil {
375+
// Continue extracting without entrypoint
376+
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), err)
377+
relPath = filepath.FromSlash(path.Join("..", partPath))
378+
}
379+
380+
dstPath := filepath.Join(funcDir, path.Base(entrypointPath), relPath)
381+
fmt.Fprintln(os.Stderr, "Extracting file:", dstPath)
382+
if err := afero.WriteReader(fsys, dstPath, part); err != nil {
383+
return errors.Errorf("failed to save file: %w", err)
384+
}
385+
386+
return nil
387+
}
388+
389+
// getPartPath extracts the filename for a multipart part, allowing for
390+
// relative paths via the custom Supabase-Path header.
391+
func getPartPath(header textproto.MIMEHeader) (string, error) {
392+
// dedicated header to specify relative path, not expected to be used
393+
if relPath := header.Get("Supabase-Path"); relPath != "" {
394+
return relPath, nil
395+
}
396+
397+
// part.FileName() does not allow us to handle relative paths, so we parse Content-Disposition manually
398+
cd := header.Get("Content-Disposition")
399+
if cd == "" {
400+
return "", nil
401+
}
402+
403+
_, params, err := mime.ParseMediaType(cd)
404+
if err != nil {
405+
return "", errors.Errorf("failed to parse content disposition: %w", err)
406+
}
407+
408+
if filename := params["filename"]; filename != "" {
409+
return filename, nil
410+
}
411+
return "", nil
412+
}

0 commit comments

Comments
 (0)