hyperverse

Écosystème R modulaire pour développer des applications modernes, à la manière du web.

Arthur Bréant

2026-05-29

Prologue

“Ce que tu fais avec Shiny, ce n’est pas du vrai web.”

Mon ancien patron

Quelques années plus tard…

Le déclic

La release de {plumber2} sur le CRAN !

🔗 Blog du Tidyverse

🔗 htmx … htmxr

htmxr

htmxr

🔥 htmx gère les interactions côté client via des attributs HTML.

🔥 plumber2 gère les points de terminaison R côté serveur.


🚀 htmxr fait le lien entre les deux grâce à :

  • des fonctions d’aide R qui génèrent le code HTML approprié et,
  • assurent l’interconnexion de l’ensemble.

🔗 hello.demo.hyperverse.world

Plutôt fast, hein ? 😏

Fast and furious

Application htmxr :

🔗 hello.demo.hyperverse.world

Applications Shiny :

via Shinyproxy : 🔗 open-apps.breant.art/app/hello-shiny

via Posit Connect : 🔗 connect.thinkr.fr/hello-shiny

fast and furious, vraiment ? 🤔

Faire du web depuis R, le web comme il a été conçu

La mécanique htmxr

Navigateur                Serveur R
   │                          │
   │  ─── GET /plot ────►     │
   │                       (R génère du HTML)
   │  ◄─── <svg>...</svg>
   │                          │
 (DOM swap)

Fast : chargement initial

-> : sur mon VPS directement.

-> Outil : Playwright.

-> Quoi mesuré : temps écoulé entre page.goto(url) et le moment où le plot SVG/IMG est réellement visible dans le DOM.

Application htmxr :

🔗 hello.demo.hyperverse.world

🔥 0.3s

Application Shiny :

🔗 open-apps.breant.art/app/hello-shiny

🔥 0.9s

Fast : interaction slider

-> : sur mon VPS directement.

-> Quoi mesuré : temps écoulé quand on bouge le slider

Application htmxr :

🔗 hello.demo.hyperverse.world

🔥 Quasi instantané

Application Shiny :

🔗 open-apps.breant.art/app/hello-shiny

🔥 Micro délai

  • le navigateur fait un GET /plot?bins=X,

  • R génère un SVG en 12 ms.

  • htmx swap dans le DOM.

-> Trois étapes, toutes standard.

  • nouvelle valeur via WebSocket,

  • invalidation du graphe réactif,

  • recalcul,

  • rendu PNG, encodage base64,

  • retour via WS,

  • update DOM.

-> Sept étapes, dont plusieurs spécifiques à Shiny.

Fast : poids client

Pourquoi ces différences ? Deps JS/CSS : 2 vs 13

La conséquence économique

Furious : l’asymétrie

htmxr (stateless HTTP) ──────────────────────

1 process R partagé

RAM ♾️ requêtes actives

User inactif = gratuit

Shiny (stateful WS) ─────────────────────

1 session R par user

RAM ♾️ users connectés

User inactif = coûte

Payer le travail réel !

Furious : la facture OVH

Avec mon VPS à 6.62 €/mois TTC :

→ 20 users Shiny en simultané (1 container/user)

→ 1000+ users htmxr (1 process pour tous)


À 500 users actifs :

htmxr → même VPS-1 → 6.62 € TTC/mois

Shiny → dédié RISE-1 64 GB → ~78 € HT/mois


🔥 HTTP est stateless par design : chaque requête est indépendante !

Furious : ce que tu hérites gratuitement

htmxr = HTTP + HTML + composants natifs du navigateur


Ce que qu’on peut récupérer gratuitement :

Cache HTTP, CDN, replicas, scaling horizontal

Monitoring et debugging avec les outils web standard

→ Déployable n’importe où (Docker, VPS, k8s, serverless)

URLs adressables

→ on paie pour le travail, pas pour les sessions

API REST (en accéléré)

L’analogie du restaurant

Client

Tu commandes

Serveur (API)

Il transmet

Cuisine (backend)

Elle prépare


Le client ne rentre jamais en cuisine.

Le serveur fournit un menu (la documentation).

L’API, c’est un contrat 🤝

Le client s’engage à :

  • Envoyer une requête bien formée
  • Respecter le format attendu

Le serveur s’engage à :

  • Répondre dans un format précis
  • Renvoyer des codes prévisibles


Comme un menu de restaurant : tu sais ce que tu commandes, à quel prix, en combien de temps.

Comme un contrat juridique : chacun connaît ses obligations.

L’interopérabilité : le pouvoir du contrat

Une seule API. N’importe quel client.


  • Avec htmxr, un endpoint est consommable de partout : navigateur, curl, Python, JS, Go, …

  • Avec Shiny, une app n’est consommable que par un navigateur qui fait tourner Shiny.

Les verbes HTTP

Verbe Sens Idempotent
GET “donne-moi”
POST “voici qqch à créer”
PUT “remplace tout”
PATCH “modifie un bout”
DELETE “supprime”


🔥 Règle d’or : chaque requête est indépendante.

Le serveur ne se souvient pas de toi entre deux requêtes : c’est le stateless qu’on a vu plus tôt.

L’astuce htmx — démo live

hx_run_example("json-endpoint")


Même donnée (diamond_data()), deux endpoints :

GET /api/rows

#* @serializer json


→ Pour curl, Python, mobile…

GET /rows

#* @serializer none (HTML)


→ Pour htmx


🔥 Même backend, deux clients.

C’est htmxr: une API REST avec une sérialisation HTML pour le navigateur.

Shiny vs htmxr : synthèse

Deux modèles de communication

Shiny htmxr
Communication WebSocket (connexion persistante) HTTP (requête / réponse)
Paradigme Graphe réactif Requêtes HTTP explicites
Mises à jour UI Shiny décide quoi recharger On cible le DOM précisément
État Session R par utilisateur Base de données ou URL
Backend R uniquement N’importe quel serveur HTTP

Une phrase à retenir

En Shiny, le serveur réagit,

En htmxr, le navigateur demande.

Présentation de htmxr

Installation

install.packages("htmxr")

# version de développement
pak::pak("hyperverse-r/htmxr")


📦 htmxr Version actuelle : v0.2.0.

🔗 Dépend de plumber2 pour le serveur HTTP.

Le cycle htmx en 4 étapes

  1. Un événement déclenche
  2. htmx envoie une requête HTTP
  3. Le serveur renvoie un fragment HTML
  4. htmx insère le fragment dans le DOM

Cinq attributs HTML, cinq paramètres R

Attribut HTML Paramètre htmxr Rôle
hx-get get requête GET
hx-post post requête POST
hx-target target sélecteur CSS cible
hx-swap swap mode d’insertion
hx-trigger trigger événement déclencheur


💡 Chaque attribut HTML de htmx correspond à un paramètre dans htmxr.

Tu écris du R, htmxr génère le HTML avec les bons attributs.

Helpers : la structure d’une page

  • hx_page() → document HTML complet
  • hx_head() → section <head>


💡 hx_page() injecte automatiquement le <script> qui charge htmx.min.js.

#* @get /
#* @parser none
#* @serializer html
function() {
  hx_page(
    hx_head(
      title = "Ma page"
    ),
    tags$div(
      class = "container",
      tags$h1("Bonjour")
    )
  )
}

📖 Les annotations plumber2 :

  • #* @get / → cette fonction répond aux requêtes GET /
  • #* @parser none → pas de parsing du body (inutile pour un GET)
  • #* @serializer html → le résultat est sérialisé en HTML

Helpers : les composants d’input

  • hx_slider_input()<input type="range">
  • hx_select_input()<select>
  • hx_button()<button>


💡 Chaque composant accepte les 5 paramètres htmx (get, target, trigger…).


🧠 Composants d’input conçus pour ressembler aux composants Shiny (sliderInput, selectInput), pour faciliter la transition mentale.

hx_slider_input(
  id = "bins",
  label = "Nombre de bins :",
  value = 30,
  min = 1,
  max = 50,
  get = "/plot",
  trigger = "input changed delay:300ms",
  target = "#plot"
)

Helpers : augmenter n’importe quel tag

🌟 hx_set() = le helper le plus puissant.


Il ajoute les attributs hx-* sur n’importe quel tag htmltools.


Utile pour transformer un <div>, <a>, <span>… existant en cible htmx.

tags$div(id = "plot") |>
  hx_set(
    get = "/plot",
    trigger = "load",
    target = "#plot",
    swap = "innerHTML"
  )

Helpers : les tables

  • hx_table() → squelette <table> avec <tbody> ciblable
  • hx_table_rows() → fragment de lignes <tr>


💡 Pattern courant : la page renvoie le squelette, un endpoint séparé renvoie les rows.

# Dans la page
hx_table(
  columns = c("nom", "prix"),
  tbody_id = "tbody",
  get = "/rows",
  trigger = "load"
)

# Dans le fragment endpoint
#* @get /rows
#* @serializer none
function() {
  hx_table_rows(
    data,
    columns = c("nom", "prix")
  )
}

Organisation d’un fichier api.R

library(htmxr)

# ── Route page complète ─────────────────
#* @get /
#* @parser none
#* @serializer html
function() {
  hx_page(
    hx_head(title = "Ma première app"),
    tags$div(
      id = "content"
    ) |>
      hx_set(
        get = "/content",
        trigger = "load",
        target = "#content"
      )
  )
}

# ── Route fragment HTML ─────────────────
#* @get /content
#* @parser none
#* @serializer html
function() {
  tags$p("Bonjour depuis le serveur !")
}

Avertissement

Pour une app complexe, ce schéma ne suffit pas.

Un équivalent de golem / rhino reste à imaginer pour standardiser modules, tests, déploiement.

Lancer l’API

library(plumber2)
library(htmxr)

api("api.R") |>
  hx_serve_assets() |>
  (\(pr) pr$ignite(block = TRUE))()


  • api() → lit api.R et crée le routeur plumber2
  • hx_serve_assets() → sert htmx.min.js comme asset statique
  • ignite() → démarre le serveur HTTP


💡 Pour tester un exemple sans rien écrire :

hx_run_example("infinity-scroll")

TD

On code ! 👨‍💻

Exercice 1

Récupérer les fichiers Gist :

  • api.R

  • run_api.R


Todo :

🔲 Lancer l’API pour faire tourner l’application

🐛 L’application se lance mais le slider ne réagit pas…

🔲 Câbler le slider

hx_slider_input(
  id = "bins",
  label = "Number of bins:",
  value = 30,
  min = 1,
  max = 50,
  get = "???",        # ← URL de l'endpoint à appeler
  trigger = "input changed delay:300ms",
  target = "???",     # ← sélecteur CSS de l'élément à mettre à jour
  class = "form-range"
)

Pour aller plus loin… 😜

Reproduire :

hx_run_example("select-input")

CSS-agnostique

htmxr ne force aucun framework CSS

Dans hello/api.R :

bootstrap_css <- tags$link(
  rel = "stylesheet",
  href = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css",
  ...
)

hx_page(
  hx_head(
    title = "Old Faithful Geyser Data",
    bootstrap_css   # ← chargé à la main
  ),
  ...
)

💡 Commenter ces 4 lignes → l’app reste fonctionnelle, juste sans style.

Shiny vs htmxr : le <head> rappel


À gauche : 2 dépendances (htmx.min.js + Bootstrap CSS).

À droite : 13 dépendances dont la moitié… ne sont pas Bootstrap.

Le coût caché de Shiny

Shiny (et bslib) complète Bootstrap par-dessus.


Classes spécifiques qu’on retrouve dans le DOM Shiny :

<div class="form-group shiny-input-container">
 ...
</div>

shiny-input-containerinventions Shiny, pas Bootstrap.


.form-group → abandonné par Bootstrap 5, réinjecté par shiny pour rétro-compatibilité.

.form-group {
  margin-bottom: 1em;
}

🤔 Conséquence : la doc Bootstrap officielle ne correspond pas à ce que tu vois dans le DOM.

La voie simple : htmxr.bootstrap

La voie simple : htmxr.bootstrap

api("api.R") |>
  hx_bs_serve_assets() |>   # ← htmx.js + Bootstrap CSS/JS
  (\(pr) pr$ignite(block = TRUE))()

#* @get /
#* @serializer html
function() {
  hx_bs_page(            # ← injecte Bootstrap automatiquement
    hx_bs_button("save", "Enregistrer", post = "/save", target = "#status")
  )
}


Bootstrap pur : classes officielles, doc Bootstrap directement applicable

Exploration :

htmxr.bootstrap::hx_bs_run_example("hello")

Le coût du roundtrip

Avez-vous remarqué ?

🔗 hello.demo.hyperverse.world

La valeur du slider ne s’affiche pas à côté du curseur.

💡 Comment l’afficher ?

Option naïve : roundtrip serveur

#* @get /slider-value
#* @query bins:integer(30)
#* @serializer html
function(query) {
  tags$span(query$bins)
}


❌ 50 requêtes/seconde quand l’utilisateur bouge le slider.

❌ Tout ça pour… afficher un nombre déjà connu côté client.

Option élégante : alpiner (Alpine.js)

library(alpiner)
x_run_example("hello")


✅ Même app que tout à l’heure, avec la valeur du slider en live.

Zéro requête HTTP pour cet affichage : tout côté client.

Les 3 lignes magiques

tags$div(

  hx_slider_input(
    id = "bins",
    ...,
    `x-model.number` = "bins"           # ← 1. binding bidirectionnel
  ),

  tags$span() |> x_set(text = "bins")   # ← 2. affichage réactif

) |> x_set(data = "{ bins: 30 }")       # ← 3. scope Alpine


💡 x-model.number = binding bidirectionnel typé number.

x_set(text = "bins") = le contenu du span suit bins.

x_set(data = ...) = déclare le scope réactif Alpine.

La règle d’or

htmxr quand il faut un aller-retour serveur,

alpiner quand c’est purement visuel / client.


🔥 Trois packages, trois rôles :

  • htmxr → interactivité serveur (DOM ↔︎ R)
  • alpiner → interactivité client (sans R)
  • htmxr.bootstrap (ou daisy, etc.) → composants stylés prêts à l’emploi

glitchtipr

Error tracking en R

Le problème


En prod, stop("...") génère un simpleError.

Tes logs : tout est simpleError.

Impossible de filtrer, prioriser, alerter.

La solution


GlitchTip : error tracking open source, self-hostable, compatible API Sentry.

glitchtipr : le wrapper R (en dev).


library(glitchtipr)

gt <- gt_connect()  # reads GLITCHTIP_DSN — inactive if not set

#* @capture
#* @get /plot
#* @parser none
#* @serializer none
function(request, query) {
  generate_plot(query$bins %||% 30)
}

Démo live

hello.demo.hyperverse.world/plot?bins=a


💥 Erreur côté serveur → remontée dans GlitchTip → visible dans la dashboard.

On en parle…


It’s a way to build apps with R differently. It’s not Shiny, and it’s not a framework. It doesn’t depend on any CSS framework, it doesn’t make choices when it comes to page structure or routing, and all logic lives in R. You’re in charge. Different philosophy 💭

The web way

Faire du web depuis R, le web comme il a été conçu.

Hyperverse

📖 Documentation officielle : hyperverse.world

Merci

hyperverse