brochure 1.0.0
First stable release. Routing is rewritten on top of routr, which brings parameterised routes and per-method pages, and fixes a family of bugs that came from keeping the current page in shared state.
Breaking changes
keysis gone. A parameterised page used to read its matched values from akeysobject thatbrochureApp()installed in the global environment: it shadowed any user object of that name and was invisible from a module, a golem app or anylocal(). Useget_keys()instead — with no argument inside a pageserver, andget_keys(request)inside a pageui.set_cookie()now defaults tohttp_only = TRUEandsame_site = "Lax". Passhttp_only = FALSEorsame_site = NULLto get the previous header.set_cookie(),remove_cookie()andserver_redirect()reject values they used to interpolate as is: a cookie name, value, domain or path outside the RFC 6265 charset, and a redirect target that is neither a path nor anhttp(s)URL....inbrochureApp()no longer accepts anything. It takespage()s,redirect()s, tags, tag lists, html dependencies and strings; anything else is an error rather than content silently injected into every page. A bare list of pages is spliced, sobrochureApp(list(page_1(), page_2()))now builds two pages instead of injecting the list into each of them.page()andredirect()gain amethodargument, so a page can answer on something other thanGET.
Bug fixes
Two people browsing two pages of the same app could each run the other’s server. The matched page was kept in a single per-app environment, written by the http handler and read back when the websocket opened. The page is now resolved per request in the ui, and per session in the server.
An unknown url served the last page visited instead of
content_404.req_handlersandres_handlerspassed topage()were stored and never run: the documented/healthcheckendpoint and the per-page cookies of the README were silent no-ops.A page whose href had more than one segment (any parameterised route) loaded none of its assets: Shiny emits its dependencies as relative urls, and the browser resolved them against the parent path. With no
shiny.jsthere was no websocket, so the page server never ran.basepathis now removed from the incoming path, as documented, and not only prepended to the urls the app emits. Apps work behind a proxy that passes the mount through as well as behind one that strips it.remove_cookie()could not delete a cookie set with an explicitpathordomain. A cookie is only replaced when name, path and domain all match, and the deletion header carried none of them, so the browser scoped it to the directory of the current request and left the original cookie alone. Behind a mount that directory is not/, so the documented login/logout pattern silently failed on Posit Connect. It now takespathanddomain.Pages and redirects are matched in the order they were passed. Redirects were registered after every page whatever the order, so a
redirect()declared before apage()at the same href never answered.redirect()accepts only the statuses a browser follows: 301, 302, 303, 307 and 308. The allow-list readc(301:308, 310), which admitted 304, 305, 306 and 310 — none of which make a browser follow aLocation.basepathhas to be a plain url path. It is interpolated into urls and into the injected javascript, where a quote in it closed the string literal.Mount rewriting is blind to capitalisation, so
<IMG SRC="logo.png">is rewritten like its lowercase equivalent.An explicit
basepathnow decides the mount even on Posit Connect. The header used to win, so an app that setbasepathto pin the behaviour was stripping the incoming path with one mount and writing its urls with another.server_redirect()andredirect()refuse a backslash anywhere in the target. A URL parser reads it as a slash, so"\page2"was the root and escaped the mount, which is only ever prefixed onto a/.Mount rewriting handles single quoted attributes, so
HTML("<img src='logo.png'>")is rewritten like its double quoted equivalent.A page declared with a method other than
GETcan serve its document. Shiny’s UI handler answersGETalone unless the ui says otherwise, so such a page returned nothing unless a request handler answered for it.server_redirect()andredirect()refuse an empty target, which is neither a path nor a url: the browser dropped it silently, and the HTTP redirect emitted an emptyLocation.set_cookie()checksmax_age, the one part still interpolated into the header unexamined:max_age = "0\r\nX-Injected: 1"appended a header of its own.server_redirect()refuses a protocol relative target written with backslashes. A URL parser reads\as/, so"\\host"and"/\host"left the app exactly as"//host"did.Mount rewriting only touches real attributes, and covers form
action. It matched anywhere in the response, so page text or inline javascript holdingsrc="logo.png"was rewritten too; and a form posting to/submitunder a mount went to the domain root.redirect()did not checkto, which goes straight into aLocationheader: a CRLF in it appended a header of its own. It is now held to the same rule asserver_redirect().An internal
redirect()sent the browser out of the app when it was mounted under a prefix. The redirect answers before anything is rewritten, so itsLocationwas emitted unprefixed.A page declared with a method other than
GETnever ran its server. The websocket handshake is aGETwhatever the page answers on, so it matched no route; sessions are now resolved against the path alone.A root absolute resource url such as
src="/img.png"was left outside the app under a mount, while a relative navigation link such as<a href="contact">was rewritten to/contact, moving where its author pointed it. Resource urls and navigation links are now treated separately.Two apps in the same R process shared their app level
req_handlersandres_handlers, and the last one built won.Formulas now work as app level handlers, as the README says they do; only page level handlers converted them.
An app behind a proxy that passes its mount through – shinyProxy, or an nginx
proxy_passwithout a trailing slash – loaded none of its assets. Shiny’s resource paths are served by httpuv before R and matched against the raw path, so/myapp/jquery-3.7.1/jquery.min.jsmatched no static path and landed on brochure, which answeredcontent_404. Withoutshiny.jsthere was no websocket, so no page was ever interactive. Such a request now falls through to the handler Shiny keeps for it. Checked from a browser againstinst/subpagebehind both kinds of proxy: 41 checks pass on each, where the pass-through one failed on the first page.-
/reactlogis served again. Every path brochure did not route gotcontent_404, and Shiny’s own handler for that endpoint comes after the app’s in the chain, sooptions(shiny.reactlog = TRUE)and Ctrl+F3 answered Two pages carrying two
bslib::bs_theme()s no longer fight over one url. Shiny builds a dependency’s url prefix from its name and version, and both themes shipbootstrapat the same version from two different compiled directories: the page rendered last took the prefix, and the browser then styled pages with whichever css it had cached under it. Each source directory now gets its own prefix.
Other
The script brochure injects now only registers the handler behind
server_redirect(). It also used to rewrite the page’s<base>, on the premise that the websocket url was computed from it; shiny builds that url fromwindow.location.pathnameinstead. Checked on a Posit Connect deployment: stripping the script from the response left a deep page with the same websocket url, the same rendered output and no failed request.get_mount()reads the mount from theRStudio-Connect-App-Base-URLheader only when the app really runs on Posit Connect, and keeps the extracted path to a plain path. Elsewhere any client could send that header and decide the prefix every url of the page is rewritten with.The routr dependency is on the CRAN release, and uuid and fastmap are no longer needed.
golem_hook()leaves a tested app: it writes a test for the page it creates, along with the testthat structure to run it underR CMD check, and adds brochure to the Imports of the app it hooks – the module template callspage(), so nothing the hook wrote could be loaded or tested without it.The golem recommended tests come in a brochure version, and
golem::use_recommended_tests()is taken out ofdev/01_start.R. The ones golem writes driveapp_ui()andapp_server(), which the hook deletes: a freshly created app failed its own test suite. What replaces them is the app object and the page it serves on/.vignette("design")says how a brochure app behaves with several visitors at once: a session is one visitor on one page, they all share one R process, and a page that blocks holds up every other page.vignette("handlers")and?pagesay why a page on a method other thanGETis unreachable when the app has awww/directory at its root: Shiny mounts it as a static path on/, and httpuv answers anything that is not aGETor aHEADfrom there with a 400 before R sees the request.Seven vignettes and a pkgdown site.
vignette("brochure")gets you started,vignette("handlers")covers the request and response middleware,vignette("cookies")the cookie API and carrying a session between pages,vignette("design")what changes when an app is several pages,vignette("deployment")serving the app under a prefix,vignette("testing")testing the routing and the pages, andvignette("golem")building a brochure app as a golem package.
