From ef4d9eeac182a3275596e916ec9cc6abdb6856e6 Mon Sep 17 00:00:00 2001 From: Victor Viana Date: Fri, 9 Jan 2026 13:13:52 -0300 Subject: [PATCH] feat(functions): implement serve individual functions --- cmd/functions.go | 16 +++- docs/supabase/functions/serve.md | 4 +- internal/functions/serve/serve.go | 25 +++--- internal/functions/serve/serve_test.go | 77 ++++++++++++++++--- internal/functions/serve/testdata/config.toml | 4 + internal/start/start.go | 4 +- 6 files changed, 102 insertions(+), 28 deletions(-) diff --git a/cmd/functions.go b/cmd/functions.go index 6b48ad662..4007d41ff 100644 --- a/cmd/functions.go +++ b/cmd/functions.go @@ -15,6 +15,7 @@ import ( "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/cast" + "github.com/supabase/cli/pkg/config" ) var ( @@ -106,13 +107,20 @@ var ( runtimeOption serve.RuntimeOption functionsServeCmd = &cobra.Command{ - Use: "serve", - Short: "Serve all Functions locally", + Use: "serve [Function name]", + Short: "Serve Functions locally", + Long: "Serve Functions locally, omit function names to serve all Functions.", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { cmd.GroupID = groupLocalDev return cmd.Root().PersistentPreRunE(cmd, args) }, RunE: func(cmd *cobra.Command, args []string) error { + for _, slug := range args { + if err := config.ValidateFunctionSlug(slug); err != nil { + return err + } + } + // Fallback to config if user did not set the flag. if !cmd.Flags().Changed("no-verify-jwt") { noVerifyJWT = nil @@ -127,7 +135,7 @@ var ( return fmt.Errorf("--inspect-main must be used together with one of these flags: [inspect inspect-mode]") } - return serve.Run(cmd.Context(), envFilePath, noVerifyJWT, importMapPath, runtimeOption, afero.NewOsFs()) + return serve.Run(cmd.Context(), args, envFilePath, noVerifyJWT, importMapPath, runtimeOption, afero.NewOsFs()) }, } ) @@ -154,7 +162,7 @@ func init() { functionsServeCmd.Flags().Var(&inspectMode, "inspect-mode", "Activate inspector capability for debugging.") functionsServeCmd.Flags().BoolVar(&runtimeOption.InspectMain, "inspect-main", false, "Allow inspecting the main worker.") functionsServeCmd.MarkFlagsMutuallyExclusive("inspect", "inspect-mode") - functionsServeCmd.Flags().Bool("all", true, "Serve all Functions.") + functionsServeCmd.Flags().Bool("all", true, "Serve all Functions.") // TODO: maybe remove this flag in next major release? it currently does nothing cobra.CheckErr(functionsServeCmd.Flags().MarkHidden("all")) downloadFlags := functionsDownloadCmd.Flags() downloadFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") diff --git a/docs/supabase/functions/serve.md b/docs/supabase/functions/serve.md index d31d9df27..b2abd1b19 100644 --- a/docs/supabase/functions/serve.md +++ b/docs/supabase/functions/serve.md @@ -1,6 +1,8 @@ ## supabase-functions-serve -Serve all Functions locally. +Serve Functions locally. + +When no function names are provided, all functions in config.toml file plus those in the `supabase/functions` directory are served. When one or more function names are specified, only those functions will be served. `supabase functions serve` command includes additional flags to assist developers in debugging Edge Functions via the v8 inspector protocol, allowing for debugging via Chrome DevTools, VS Code, and IntelliJ IDEA for example. Refer to the [docs guide](/docs/guides/functions/debugging-tools) for setup instructions. diff --git a/internal/functions/serve/serve.go b/internal/functions/serve/serve.go index b0db41ffd..f69668dad 100644 --- a/internal/functions/serve/serve.go +++ b/internal/functions/serve/serve.go @@ -70,7 +70,7 @@ const ( //go:embed templates/main.ts var mainFuncEmbed string -func Run(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, runtimeOption RuntimeOption, fsys afero.Fs) error { +func Run(ctx context.Context, slugs []string, envFilePath string, noVerifyJWT *bool, importMapPath string, runtimeOption RuntimeOption, fsys afero.Fs) error { watcher, err := NewDebounceFileWatcher() if err != nil { return err @@ -79,7 +79,7 @@ func Run(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPa defer watcher.Close() // TODO: refactor this to edge runtime service runtimeOption.fileWatcher = watcher - if err := restartEdgeRuntime(ctx, envFilePath, noVerifyJWT, importMapPath, runtimeOption, fsys); err != nil { + if err := restartEdgeRuntime(ctx, slugs, envFilePath, noVerifyJWT, importMapPath, runtimeOption, fsys); err != nil { return err } streamer := NewLogStreamer(ctx) @@ -91,7 +91,7 @@ func Run(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPa fmt.Println("Stopped serving " + utils.Bold(utils.FunctionsDir)) return ctx.Err() case <-watcher.RestartCh: - if err := restartEdgeRuntime(ctx, envFilePath, noVerifyJWT, importMapPath, runtimeOption, fsys); err != nil { + if err := restartEdgeRuntime(ctx, slugs, envFilePath, noVerifyJWT, importMapPath, runtimeOption, fsys); err != nil { return err } case err := <-streamer.ErrCh: @@ -100,7 +100,7 @@ func Run(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPa } } -func restartEdgeRuntime(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, runtimeOption RuntimeOption, fsys afero.Fs) error { +func restartEdgeRuntime(ctx context.Context, slugs []string, envFilePath string, noVerifyJWT *bool, importMapPath string, runtimeOption RuntimeOption, fsys afero.Fs) error { // 1. Sanity checks. if err := flags.LoadConfig(fsys); err != nil { return err @@ -117,10 +117,10 @@ func restartEdgeRuntime(ctx context.Context, envFilePath string, noVerifyJWT *bo dbUrl := fmt.Sprintf("postgresql://postgres:postgres@%s:5432/postgres", utils.DbAliases[0]) // 3. Serve and log to console fmt.Fprintln(os.Stderr, "Setting up Edge Functions runtime...") - return ServeFunctions(ctx, envFilePath, noVerifyJWT, importMapPath, dbUrl, runtimeOption, fsys) + return ServeFunctions(ctx, slugs, envFilePath, noVerifyJWT, importMapPath, dbUrl, runtimeOption, fsys) } -func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, dbUrl string, runtimeOption RuntimeOption, fsys afero.Fs) error { +func ServeFunctions(ctx context.Context, slugs []string, envFilePath string, noVerifyJWT *bool, importMapPath string, dbUrl string, runtimeOption RuntimeOption, fsys afero.Fs) error { // 1. Parse custom env file env, err := parseEnvFile(envFilePath, fsys) if err != nil { @@ -153,7 +153,7 @@ func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool, return errors.Errorf("failed to resolve relative path: %w", err) } } - binds, functionsConfigString, err := PopulatePerFunctionConfigs(cwd, importMapPath, noVerifyJWT, fsys) + binds, functionsConfigString, err := PopulatePerFunctionConfigs(slugs, cwd, importMapPath, noVerifyJWT, fsys) if err != nil { return err } @@ -241,10 +241,13 @@ func parseEnvFile(envFilePath string, fsys afero.Fs) ([]string, error) { return env, err } -func PopulatePerFunctionConfigs(cwd, importMapPath string, noVerifyJWT *bool, fsys afero.Fs) ([]string, string, error) { - slugs, err := deploy.GetFunctionSlugs(fsys) - if err != nil { - return nil, "", err +func PopulatePerFunctionConfigs(slugs []string, cwd, importMapPath string, noVerifyJWT *bool, fsys afero.Fs) ([]string, string, error) { + var err error + if len(slugs) == 0 { + slugs, err = deploy.GetFunctionSlugs(fsys) + if err != nil { + return nil, "", err + } } functionsConfig, err := deploy.GetFunctionConfig(slugs, importMapPath, noVerifyJWT, fsys) if err != nil { diff --git a/internal/functions/serve/serve_test.go b/internal/functions/serve/serve_test.go index 38b7f420c..7ad8ad9ff 100644 --- a/internal/functions/serve/serve_test.go +++ b/internal/functions/serve/serve_test.go @@ -46,7 +46,7 @@ func TestServeCommand(t *testing.T) { apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.EdgeRuntime.Image), containerId) require.NoError(t, apitest.MockDockerLogsStream(utils.Docker, containerId, 1, strings.NewReader("failed"))) // Run test with timeout context - err := Run(context.Background(), "", nil, "", RuntimeOption{}, fsys) + err := Run(context.Background(), nil, "", nil, "", RuntimeOption{}, fsys) // Check error assert.ErrorContains(t, err, "error running container: exit 1") assert.Empty(t, apitest.ListUnmatchedRequests()) @@ -57,7 +57,7 @@ func TestServeCommand(t *testing.T) { fsys := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("malformed"), 0644)) // Run test - err := Run(context.Background(), "", nil, "", RuntimeOption{}, fsys) + err := Run(context.Background(), nil, "", nil, "", RuntimeOption{}, fsys) // Check error assert.ErrorContains(t, err, "toml: expected = after a key, but the document ends there") }) @@ -73,7 +73,7 @@ func TestServeCommand(t *testing.T) { Get("/v" + utils.Docker.ClientVersion() + "/containers/supabase_db_test/json"). Reply(http.StatusNotFound) // Run test - err := Run(context.Background(), "", nil, "", RuntimeOption{}, fsys) + err := Run(context.Background(), nil, "", nil, "", RuntimeOption{}, fsys) // Check error assert.ErrorIs(t, err, utils.ErrNotRunning) }) @@ -90,7 +90,7 @@ func TestServeCommand(t *testing.T) { Reply(http.StatusOK). JSON(container.InspectResponse{}) // Run test - err := Run(context.Background(), ".env", nil, "", RuntimeOption{}, fsys) + err := Run(context.Background(), nil, ".env", nil, "", RuntimeOption{}, fsys) // Check error assert.ErrorContains(t, err, "open .env: file does not exist") }) @@ -110,7 +110,7 @@ func TestServeCommand(t *testing.T) { Reply(http.StatusOK). JSON(container.InspectResponse{}) // Run test - err := Run(context.Background(), ".env", cast.Ptr(true), "import_map.json", RuntimeOption{}, fsys) + err := Run(context.Background(), nil, ".env", cast.Ptr(true), "import_map.json", RuntimeOption{}, fsys) // Check error assert.ErrorContains(t, err, "failed to resolve relative path:") }) @@ -128,7 +128,7 @@ func TestServeFunctions(t *testing.T) { defer gock.OffAll() apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.EdgeRuntime.Image), utils.EdgeRuntimeId) // Run test - err := ServeFunctions(context.Background(), "", nil, "", "", RuntimeOption{ + err := ServeFunctions(context.Background(), nil, "", nil, "", "", RuntimeOption{ InspectMode: cast.Ptr(InspectModeRun), InspectMain: true, }, fsys) @@ -157,17 +157,74 @@ func TestServeFunctions(t *testing.T) { }, env) }) - t.Run("parses function config", func(t *testing.T) { + t.Run("parses function config for all functions", func(t *testing.T) { // Setup in-memory fs fsys := afero.FromIOFS{FS: testdata} - // Run test - binds, configString, err := PopulatePerFunctionConfigs("/", "", nil, fsys) + // Run test with nil slugs (serve all) + binds, configString, err := PopulatePerFunctionConfigs(nil, "/", "", nil, fsys) // Check error assert.NoError(t, err) assert.ElementsMatch(t, []string{ "supabase_edge_runtime_test:/root/.cache/deno:rw", "/supabase/functions/:/supabase/functions/:ro", }, binds) - assert.Equal(t, `{"hello":{"verifyJWT":true,"entrypointPath":"testdata/functions/hello/index.ts","staticFiles":["testdata/image.png"]}}`, configString) + // Should contain hello, good, bye but NOT world (disabled) + assert.Contains(t, configString, `"hello"`) + assert.Contains(t, configString, `"good"`) + assert.Contains(t, configString, `"bye"`) + assert.NotContains(t, configString, `"world"`) + }) + + t.Run("serves only disabled function returns empty config", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.FromIOFS{FS: testdata} + // Run test with only disabled function + _, configString, err := PopulatePerFunctionConfigs([]string{"world"}, "/", "", nil, fsys) + // Check error + assert.NoError(t, err) + // Config should be empty since world is disabled + assert.Equal(t, "{}", configString) + }) + + t.Run("serves single specific function", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.FromIOFS{FS: testdata} + // Run test with single slug + _, configString, err := PopulatePerFunctionConfigs([]string{"hello"}, "/", "", nil, fsys) + // Check error + assert.NoError(t, err) + // Should only contain hello + assert.Contains(t, configString, `"hello"`) + assert.NotContains(t, configString, `"good"`) + assert.NotContains(t, configString, `"bye"`) + assert.NotContains(t, configString, `"world"`) + }) + + t.Run("serves multiple specific enabled functions", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.FromIOFS{FS: testdata} + // Run test with multiple enabled slugs + _, configString, err := PopulatePerFunctionConfigs([]string{"hello", "good", "bye"}, "/", "", nil, fsys) + // Check error + assert.NoError(t, err) + // Should contain all three + assert.Contains(t, configString, `"hello"`) + assert.Contains(t, configString, `"good"`) + assert.Contains(t, configString, `"bye"`) + assert.NotContains(t, configString, `"world"`) + }) + + t.Run("serves multiple functions skipping disabled ones", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.FromIOFS{FS: testdata} + // Run test with mix of enabled and disabled slugs + _, configString, err := PopulatePerFunctionConfigs([]string{"hello", "world", "good", "bye"}, "/", "", nil, fsys) + // Check error + assert.NoError(t, err) + // Should contain hello, good, bye but NOT world (disabled) + assert.Contains(t, configString, `"hello"`) + assert.Contains(t, configString, `"good"`) + assert.Contains(t, configString, `"bye"`) + assert.NotContains(t, configString, `"world"`) }) } diff --git a/internal/functions/serve/testdata/config.toml b/internal/functions/serve/testdata/config.toml index 28ea9a510..3f489ce77 100644 --- a/internal/functions/serve/testdata/config.toml +++ b/internal/functions/serve/testdata/config.toml @@ -6,3 +6,7 @@ static_files = ["image.png"] [functions.world] enabled = false verify_jwt = false + +[functions.good] + +[functions.bye] diff --git a/internal/start/start.go b/internal/start/start.go index 1184b6590..9a8bd7c7a 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -1094,7 +1094,7 @@ EOF // Start all functions. if utils.Config.EdgeRuntime.Enabled && !isContainerExcluded(utils.Config.EdgeRuntime.Image, excluded) { dbUrl := fmt.Sprintf("postgresql://%s:%s@%s:%d/%s", dbConfig.User, dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database) - if err := serve.ServeFunctions(ctx, "", nil, "", dbUrl, serve.RuntimeOption{}, fsys); err != nil { + if err := serve.ServeFunctions(ctx, nil, "", nil, "", dbUrl, serve.RuntimeOption{}, fsys); err != nil { return err } started = append(started, utils.EdgeRuntimeId) @@ -1140,7 +1140,7 @@ EOF // Start Studio. if utils.Config.Studio.Enabled && !isContainerExcluded(utils.Config.Studio.Image, excluded) { - binds, _, err := serve.PopulatePerFunctionConfigs(workdir, "", nil, fsys) + binds, _, err := serve.PopulatePerFunctionConfigs(nil, workdir, "", nil, fsys) if err != nil { return err }