Hi, thank you for your suggetion. This has helped me to reverse engineer the issue:
Undesired Behavior
With a golem-style app-layout and an additional plumber API in inst/plumber/api/plumber.R
, rsconnect:::inferAppMode()
infers that the project has appmode: api
, not appmode: shiny
.
This function is called when using rsconnect::writeManifest()
, rsconnect::bundleApp()
, which is called by rsconnect::deployApp()
, as well as when using the deploy button in the RStudio interface.
For example:
> rsconnect::writeManifest()
...
> jsonlite::read_json("manifest.json")$metadata$appmode
# [1] "api"
Consequently, rsconnect::deployApp()
and the deployButton will wrongly try to deploy the project as an API instead of as a shiny app.
Creating a manifest.json
in the directory and simply changing appmode
to shiny
in that file doesn't solve the issue, because deployApp()
calls bundleApp()
, which generates its own manifest.json
straight in a temporary bundle directory, so the manifest.json
file in the project directory is ignored.
The root of the Problem
rsconnect:::inferAppMode()
checks for evidence of a plumber API (and returns) before it checks for evidence of a Shiny App (as can be seen here).
The behavior was introduced in rsconnect version 1.0.0, because
- Prior to 1.0.0,
inferAppMode()
was called on a non-recursive list of the files in in the directory via bundleFiles()
, i.e., only the top level was searched for evidence of the different app modes (so inst/api/plumber.R
would not have been part of the list).
- Since 1.0.0, the function gets a recursive list of all files in the bundle (via
listDeploymentFiles()
), which, in our case, now includes 'inst/api/plumber.R`
- This is due to a change in the internal function
listBundleFIles()
, which now calls a function that returns a recursive list of files (old version in 0.8.29 vs. new version in 1.0.0)
- This is despite a comment stating that it only checks files in the root dir
Suggested solution: setting appPrimaryDoc = "app.R"
This throws an error:
> rsconnect::writeManifest(appPrimaryDoc = "app.R")`
Error in `checkAppLayout()`:
! Project must not contain both app.R and a single-file Shiny app.
Run `rlang::last_trace()` to see where the error occurred.
This error is thrown by a conditional in rsconnect::checkAppLayout(appDir, appPrimaryDoc)
, a function that checks whether the app directory has any of the expected layouts. This function is called by both, writeManifest()
and bundleApp()
via rsconnect:::appMetadata()
and it explicitely prevents the user from setting app.R
to be the appPrimaryDoc
:
appFilesBase <- tolower(list.files(appDir))
...
primaryIsRScript <- identical(tolower(tools::file_ext(appPrimaryDoc)), "r")
if (primaryIsRScript && "app.r" %in% appFilesBase) {
cli::cli_abort("Project must not contain both {.file app.R} and a single-file Shiny app.")
}
A solution is to add an additional condition to test, whether appPrimaryDoc == app.R
. Ideally, this would have to be fixed in {rsconnect}
itself, but one can temporarily inject a fix like this:
checkAppLayout_modified <- function (appDir, appPrimaryDoc = NULL)
{
...
if (primaryIsRScript && "app.r" %in% appFilesBase && tolower(appPrimaryDoc) != "app.r") {
cli::cli_abort("Project must not contain both {.file app.R} and a single-file Shiny app.")
}
...
}
assignInNamespace("checkAppLayout", checkAppLayout_modified, ns = "rsconnect")
rsconnect::writeManifest(appPrimaryDoc = "app.R")
Complete functional code
# A solution is to prevent the error by checking whether `tolower(appPrimaryDoc) != "app.r"`
checkAppLayout_original <- rsconnect:::checkAppLayout # for restoring it later
checkAppLayout_modified <- function (appDir, appPrimaryDoc = NULL)
{
cli::cli_alert_warning("Using custom function injected into the rsconnect namespace.")
appFilesBase <- tolower(list.files(appDir))
wwwFiles <- tolower(list.files(file.path(appDir, "www/")))
primaryIsRScript <- identical(tolower(tools::file_ext(appPrimaryDoc)),
"r")
if (primaryIsRScript && "app.r" %in% appFilesBase && tolower(appPrimaryDoc) != "app.r") {
cli::cli_abort("Project must not contain both {.file app.R} and a single-file Shiny app.")
}
satisfiedLayouts <- c(shinyAndUi = all(c("server.r", "ui.r") %in%
appFilesBase), shinyAndIndex = "server.r" %in% appFilesBase &&
"index.html" %in% wwwFiles, app = primaryIsRScript ||
any("app.r" %in% appFilesBase), Rmd = any(grepl(glob2rx("*.rmd"),
appFilesBase)), Qmd = any(grepl(glob2rx("*.qmd"), appFilesBase)),
static = any(grepl("(?:html?|pdf)$", appFilesBase)),
plumber = any(c("entrypoint.r", "plumber.r") %in% appFilesBase))
if (any(satisfiedLayouts)) {
return()
}
cli::cli_abort(c("Cancelling deployment: invalid project layout.",
i = "Expecting one of the following publication types:",
` ` = "1. A Shiny app with `app.R` or `server.R` + `ui.R`",
` ` = "2. R Markdown (`.Rmd`) or Quarto (`.qmd`) documents.",
` ` = "3. A website containing `.html` and/or `.pdf` files.",
` ` = "4. A plumber API with `plumber.R` or `entrypoint.R`."))
}
assignInNamespace("checkAppLayout", checkAppLayout_modified, ns = "rsconnect")
rsconnect::writeManifest(appPrimaryDoc = "app.R")
appmode <- jsonlite::read_json("manifest.json")$metadata$appmode
if (appmode == "shiny") {
rsconnect::deployApp(appPrimaryDoc = "app.R")
} else {
cli::cli_abort("Something went wrong, `appmode` is `{ appmode }`.")
}
This allows me to publish to rsconnect succesfully, since the erorr isn't thrown anymore and the generated manifest.json
now has appmode: shiny
. However, the solution requires some R black magic and it still doesn't allow me to use the deploy Button in RStudio, because there I cannot supply the appPrimaryDoc
argument.
It looks like currently there is no straightforward solution to use the project structure we have with the latest version of rsconnect.
Generally, I think, the preference of an API over an App by deployApp()
is unexpected and should be avoided. If I wanted to deploy the API that's in the package, I'd expect to use deployApi()
to do so.
There are a number of potential upstream fixes I can think of, but I think it's best if the project maintainers look into this.
I suppose I will open an issue on Github about this.
Is there any simple solution I'm overlooking?