Skip to contents

Two 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.

# v1 (BROKEN under plumber2 — msg is always "")
#* @get /api/echo
function(msg = "") list(echo = msg)

# plumber2
#* @get /api/echo
function(query) list(echo = query$msg %||% "")

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.

Testing a ported handler

pa$test_request(fiery::fake_request(url, method=, content=, headers=)) runs a request through the assembled API without binding a port — handy for fast, deterministic checks while you port.