Migrating from Shiny (and plumber v1)
Source:vignettes/migrating-from-shiny.Rmd
migrating-from-shiny.RmdTwo audiences land here: people coming from Shiny (reactive server) and people porting a plumber v1 API (aurora targets plumber2 only). This covers both shifts.
From Shiny: the mental-model shift
| Shiny | aurora |
|---|---|
| Reactive server holds state per user | Stateless: state lives in the client or an external store |
ui <- fluidPage(...) |
build_ui() returns an htmltools/bslib tag, compiled to
static HTML |
server <- function(input, output) {...} |
routers/*.R — plumber2 handlers returning JSON |
reactive() / observe()
|
JS fetch (aurora.json(...)) + DOM updates
in app.js
|
input$x |
request query / body / path params |
output$y <- render*() |
a JSON response a handler returns |
renderPlot/renderDT widgets |
client-side libraries (ECharts, MapLibre, DataTables) fed by
/api
|
| shinyapps.io / Connect, sticky sessions | Docker / ShinyProxy, horizontally scalable |
What you keep: the bslib UI transfers almost verbatim. What changes: server logic becomes JSON endpoints, and you write some JavaScript to render. What you gain: no per-user R process, trivial horizontal scaling, CDN-cacheable UI.
A reactive value that recomputed when an input changed becomes: a DOM
event → aurora.json("api/...") → update the DOM. Read-only
datasets that you loaded once at app start map onto
aurora_data_store() (see
vignette("aurora")).
From plumber v1: it is not a find-and-replace
plumber2 is API-incompatible with plumber. The five changes that actually bite:
1. Query params no longer bind to named handler args
Only path parameters (<var> in
the annotation) become named arguments. Read the query string from the
reserved query argument and a parsed body from
body.
2. req/res become reqres
request/response
The reserved handler arguments are request,
response, query, body,
server, client_id — not
req/res. Translation table:
| Need | plumber v1 | plumber2 / reqres |
|---|---|---|
| Path param | named arg (:var) |
named arg (<var>) |
| Query value | named arg | query$x |
| Parsed body | req$body$x |
body$x (needs @parser json) |
| Request method / path |
req$REQUEST_METHOD / req$PATH_INFO
|
request$method / request$path
|
| A request header | req$HTTP_X_FOO |
request$get_header("X-Foo") |
| Cookies | parse req$HTTP_COOKIE by hand |
request$cookies$name (auto-parsed) |
| Set status | res$status <- 401 |
response$status <- 401L |
| Set header | res$setHeader(n, v) |
response$set_header(n, v) |
| Set / clear cookie | manual Set-Cookie
|
response$set_cookie(...) /
response$clear_cookie()
|
| Abort with a code | res$status <- n; return(...) |
reqres::abort_unauthorized() /
abort_bad_request()
|
| Continue / stop chain |
forward() / return()
|
return plumber2::Next /
plumber2::Break
|
| Logging | cat() |
server$log("message", ...) |
Note: reqres set_cookie(same_site=) wants
"Lax"/"Strict"/"None"
(capitalised). length-1 vectors are not auto-unboxed by
the json serializer, so scalars serialize as 1-element
arrays — jsonlite::unbox() them where a scalar is required
(or use a dedicated serializer like geojson).
3. No @filter / preempt /
forward()
Removed. Use a route chain instead: a header-route
handler (@header) runs before the body and can reject
early; return Next to continue or Break to
stop; throw reqres::abort_*() to fail with a status.
aurora’s auth template uses exactly this for its
/api/* guard (see vignette("auth")).
4. pr_*() → api_*() (not 1:1)
pr()/pr_mount() → api() +
api_parse(); pr_static() →
api_assets(); pr_hook("exit", ...) →
api_on("end", ...). aurora already does this assembly for
you in aurora_app().
5. No mount-prefixing — the path lives in the annotation
v1 mounted a router under a prefix; aurora bakes the full path into
the annotation (#* @get /api/iniciativas/data).
aurora_add_route() writes it for you; porting a mounted v1
router means rewriting each annotation to its full path.