@@ -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
238259func suggestLegacyBundle (slug string ) string {
239260 return fmt .Sprintf ("\n If 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