nanonext - Web Toolkit

library(nanonext)

nanonext provides high-performance HTTP/WebSocket client and server capabilities built on NNG’s networking stack with Mbed TLS for secure connections.

1. HTTP Client

ncurl: Basic Requests

ncurl() is a minimalist HTTP(S) client. Basic usage requires only a URL.

ncurl("https://postman-echo.com/get")
#> $status
#> [1] 200
#> 
#> $headers
#> NULL
#> 
#> $data
#> [1] "{\"args\":{},\"headers\":{\"host\":\"postman-echo.com\",\"accept-encoding\":\"gzip, br\",\"x-forwarded-proto\":\"https\"},\"url\":\"https://postman-echo.com/get\"}"

Advanced usage supports all HTTP methods (POST, PUT, DELETE, etc.), custom headers, and request bodies.

ncurl("https://postman-echo.com/post",
      method = "POST",
      headers = c(`Content-Type` = "application/json", Authorization = "Bearer APIKEY"),
      data = '{"key": "value"}',
      response = "date")
#> $status
#> [1] 200
#> 
#> $headers
#> $headers$date
#> [1] "Sun, 08 Feb 2026 11:22:45 GMT"
#> 
#> 
#> $data
#> [1] "{\"args\":{},\"data\":{\"key\":\"value\"},\"files\":{},\"form\":{},\"headers\":{\"host\":\"postman-echo.com\",\"accept-encoding\":\"gzip, br\",\"x-forwarded-proto\":\"https\",\"content-type\":\"application/json\",\"authorization\":\"Bearer APIKEY\",\"content-length\":\"16\"},\"json\":{\"key\":\"value\"},\"url\":\"https://postman-echo.com/post\"}"

Specify response = TRUE to return all response headers.

ncurl("https://postman-echo.com/get",
      response = TRUE)
#> $status
#> [1] 200
#> 
#> $headers
#> $headers$Date
#> [1] "Sun, 08 Feb 2026 11:22:45 GMT"
#> 
#> $headers$`Content-Type`
#> [1] "application/json; charset=utf-8"
#> 
#> $headers$`Content-Length`
#> [1] "143"
#> 
#> $headers$Connection
#> [1] "close"
#> 
#> $headers$`CF-RAY`
#> [1] "9caac0420a1dc16b-LHR"
#> 
#> $headers$etag
#> [1] "W/\"8f-7zN8nSad8A9WlFJjKQZB04z5nHE\""
#> 
#> $headers$vary
#> [1] "Accept-Encoding"
#> 
#> $headers$`Set-Cookie`
#> [1] "sails.sid=s%3A3tYl-iuzp8jF0j82bxVHtxrx87HF8PoG.ykEiyyCKGOB1tWjbdRBXQQN7R2JwAHxl%2FkpAWvaV%2FIs; Path=/; HttpOnly, __cf_bm=xmba.Nkum1545BuZtIF1AkAAJzxI0VLZrdSg2QarH6M-1770549765-1.0.1.1-Twtq9B6vpPFSSJbuRQ9AaEDUB83Sprl4keRU_kvVk7PZqFGUZc1cVqFvyfN14NRX1CG5qT1idcqkSWejx03q6v3KYkPgCga6vJiifoRoVUs; path=/; expires=Sun, 08-Feb-26 11:52:45 GMT; domain=.postman-echo.com; HttpOnly; Secure, _cfuvid=u2FHSl3lIzG1PMEmFIe9J58inYS1BQ8KH5f7Y3RV1Nk-1770549765548-0.0.1.1-604800000; path=/; domain=.postman-echo.com; HttpOnly; Secure; SameSite=None"
#> 
#> $headers$`x-envoy-upstream-service-time`
#> [1] "5"
#> 
#> $headers$`cf-cache-status`
#> [1] "DYNAMIC"
#> 
#> $headers$Server
#> [1] "cloudflare"
#> 
#> 
#> $data
#> [1] "{\"args\":{},\"headers\":{\"host\":\"postman-echo.com\",\"accept-encoding\":\"gzip, br\",\"x-forwarded-proto\":\"https\"},\"url\":\"https://postman-echo.com/get\"}"

ncurl_aio: Async Requests

ncurl_aio() performs asynchronous requests, returning immediately with an ‘ncurlAio’ object that resolves when the response arrives.

res <- ncurl_aio("https://postman-echo.com/post",
                 method = "POST",
                 headers = c(`Content-Type` = "application/json"),
                 data = '{"async": true}',
                 response = "date")
res
#> < ncurlAio | $status $headers $data >

call_aio(res)$headers
#> $date
#> [1] "Sun, 08 Feb 2026 11:22:45 GMT"

res$status
#> [1] 200

res$data
#> [1] "{\"args\":{},\"data\":{\"async\":true},\"files\":{},\"form\":{},\"headers\":{\"host\":\"postman-echo.com\",\"content-type\":\"application/json\",\"accept-encoding\":\"gzip, br\",\"x-forwarded-proto\":\"https\",\"content-length\":\"15\"},\"json\":{\"async\":true},\"url\":\"https://postman-echo.com/post\"}"
Promises Integration

‘ncurlAio’ objects work anywhere that accepts a ‘promise’ from the promises package, including Shiny ExtendedTask.

library(promises)

p <- ncurl_aio("https://postman-echo.com/get") |> then(\(x) cat(x$data))
is.promise(p)
#> [1] TRUE

ncurl_session: Persistent Connections

ncurl_session() creates a reusable connection for efficient repeated requests to an API endpoint. Use transact() to send requests over the session.

sess <- ncurl_session("https://postman-echo.com/get",
                      convert = FALSE,
                      headers = c(`Content-Type` = "application/json"),
                      response = c("Date", "Content-Type"))
sess
#> < ncurlSession > - transact() to return data

transact(sess)
#> $status
#> [1] 200
#> 
#> $headers
#> $headers$Date
#> [1] "Sun, 08 Feb 2026 11:22:46 GMT"
#> 
#> $headers$`Content-Type`
#> [1] "application/json; charset=utf-8"
#> 
#> 
#> $data
#>   [1] 7b 22 61 72 67 73 22 3a 7b 7d 2c 22 68 65 61 64 65 72 73 22 3a 7b 22 68 6f 73 74 22 3a
#>  [30] 22 70 6f 73 74 6d 61 6e 2d 65 63 68 6f 2e 63 6f 6d 22 2c 22 61 63 63 65 70 74 2d 65 6e
#>  [59] 63 6f 64 69 6e 67 22 3a 22 67 7a 69 70 2c 20 62 72 22 2c 22 78 2d 66 6f 72 77 61 72 64
#>  [88] 65 64 2d 70 72 6f 74 6f 22 3a 22 68 74 74 70 73 22 2c 22 63 6f 6e 74 65 6e 74 2d 74 79
#> [117] 70 65 22 3a 22 61 70 70 6c 69 63 61 74 69 6f 6e 2f 6a 73 6f 6e 22 7d 2c 22 75 72 6c 22
#> [146] 3a 22 68 74 74 70 73 3a 2f 2f 70 6f 73 74 6d 61 6e 2d 65 63 68 6f 2e 63 6f 6d 2f 67 65
#> [175] 74 22 7d

close(sess)

2. WebSocket Client

stream() provides a low-level byte stream interface for communicating with WebSocket servers and other non-NNG endpoints.

Use textframes = TRUE for servers that expect text frames (most WebSocket servers).

s <- stream(dial = "wss://echo.websocket.org/", textframes = TRUE)
s
#> < nanoStream >
#>  - mode: dialer text frames
#>  - state: opened
#>  - url: wss://echo.websocket.org/

send() and recv(), along with their async counterparts send_aio() and recv_aio(), work on Streams just like Sockets.

s |> recv()
#> [1] "Request served by 4d896d95b55478"

s |> send("hello websocket")
#> [1] 0

s |> recv()
#> [1] "hello websocket"

s |> recv_aio() -> r

s |> send("async message")
#> [1] 0

r[]
#> [1] "async message"

close(s)

3. Unified HTTP/WebSocket Server

http_server() creates a single server that can handle HTTP requests, WebSocket connections, and HTTP streaming, all on the same port.

A single call to http_server() sets up one NNG server instance with a list of handlers. HTTP routes, WebSocket endpoints, streaming endpoints, and static file handlers all share the same underlying server – there is no need to run separate processes or bind additional ports. WebSocket clients connect via the standard HTTP upgrade mechanism, so a browser can load a page over HTTP and open a WebSocket connection to the same origin without any cross-origin configuration.

server <- http_server(
  url = "http://127.0.0.1:8080",
  handlers = list(
    handler("/", function(req) {
      list(status = 200L, body = "Hello from nanonext!")
    }),
    handler("/api/data", function(req) {
      list(
        status = 200L,
        headers = c("Content-Type" = "application/json"),
        body = '{"value": 42}'
      )
    }, method = "GET")
  )
)
server$start()
# Process requests: repeat later::run_now(Inf)
server$close()

Specifying port 0 in the URL lets the OS assign an available port. The actual port is reflected in server$url after $start(), making it easy to set up test servers without port conflicts.

Handler Types

All handler types can be freely mixed in a single server’s handler list:

Handler Purpose
handler() HTTP request/response with R callback
handler_ws() WebSocket with on_message, on_open, on_close callbacks
handler_stream() Chunked HTTP streaming (SSE, NDJSON, custom)
handler_file() Serve a single static file
handler_directory() Serve a directory tree with automatic MIME types
handler_inline() Serve in-memory content
handler_redirect() HTTP redirect

HTTP Request Handlers

handler() creates HTTP route handlers. The callback receives a request list with method, uri, headers, and body, and returns a response list with status, optional headers, and body.

# GET endpoint
h1 <- handler("/hello", function(req) {
  list(status = 200L, body = "Hello!")
})

# POST endpoint echoing the request body
h2 <- handler("/echo", function(req) {
  list(status = 200L, body = req$body)
}, method = "POST")

# Catch-all for any method under a path prefix
h3 <- handler("/api", function(req) {
  list(
    status = 200L,
    headers = c("Content-Type" = "application/json"),
    body = sprintf('{"method":"%s","uri":"%s"}', req$method, req$uri)
  )
}, method = "*", prefix = TRUE)

Static Content Handlers

# Serve a single file
h_file <- handler_file("/favicon.ico", "path/to/favicon.ico")

# Serve a directory tree (automatic MIME type detection)
h_dir <- handler_directory("/static", "www/assets")

# Serve inline content
h_inline <- handler_inline("/robots.txt", "User-agent: *\nDisallow:",
                           content_type = "text/plain")

# Redirect requests
h_redirect <- handler_redirect("/old-page", "/new-page", status = 301L)

WebSocket Handlers

WebSockets provide full bidirectional communication – the server can push messages to the client, and the client can send messages back.

handler_ws() creates WebSocket endpoints. NNG handles the HTTP upgrade handshake and all WebSocket framing (RFC 6455) automatically. Because WebSocket handlers share the same server as HTTP handlers, the browser can load a page and open a WebSocket to the same host and port with no additional setup.

clients <- list()

server <- http_server(
  url = "http://127.0.0.1:8080",
  handlers = list(
    handler_ws(
      "/chat",
      on_message = function(ws, data) {
        # Broadcast to all connected clients
        for (client in clients) client$send(data)
      },
      on_open = function(ws) {
        clients[[as.character(ws$id)]] <<- ws
      },
      on_close = function(ws) {
        clients[[as.character(ws$id)]] <<- NULL
      },
      textframes = TRUE
    )
  )
)
server$start()

The ws connection object provides:

Multiple WebSocket endpoints can coexist on the same server, each with independent callbacks and connection tracking. Connection IDs are unique across the entire server, so they are safe to use as keys in a shared data structure spanning multiple handlers.

HTTP Streaming Handlers

When you only need to push data in one direction – server to client – streaming is a lighter-weight alternative to WebSockets. It works over plain HTTP, so any client that speaks HTTP can consume the stream without needing a WebSocket library.

handler_stream() enables HTTP streaming using chunked transfer encoding, supporting Server-Sent Events (SSE), newline-delimited JSON (NDJSON), and custom streaming formats. Like WebSocket handlers, streaming endpoints share the same server as all other handlers.

conns <- list()

server <- http_server(
  url = "http://127.0.0.1:8080",
  handlers = list(
    # SSE endpoint
    handler_stream("/events",
      on_request = function(conn, req) {
        conn$set_header("Content-Type", "text/event-stream")
        conn$set_header("Cache-Control", "no-cache")
        conns[[as.character(conn$id)]] <<- conn
        conn$send(format_sse(data = "connected", id = "1"))
      },
      on_close = function(conn) {
        conns[[as.character(conn$id)]] <<- NULL
      }
    ),
    # Trigger broadcast via POST
    handler("/broadcast", function(req) {
      msg <- format_sse(data = rawToChar(req$body), event = "message")
      lapply(conns, function(c) c$send(msg))
      list(status = 200L, body = "sent")
    }, method = "POST")
  )
)
server$start()

Server-Sent Events

format_sse() formats messages according to the SSE specification for browser EventSource clients.

format_sse(data = "Hello")
#> [1] "data: Hello\n\n"

format_sse(data = "Update available", event = "notification", id = "42")
#> [1] "event: notification\nid: 42\ndata: Update available\n\n"

format_sse(data = "Line 1\nLine 2")
#> [1] "data: Line 1\ndata: Line 2\n\n"

The streaming connection object provides:

4. Secure Connections (TLS)

All web functions support TLS for secure HTTPS/WSS connections via tls_config().

Public Internet HTTPS

When making HTTPS requests over the public internet, you should supply a TLS configuration to validate server certificates.

Root CA certificates in PEM format may be found at:

tls <- tls_config(client = "/etc/ssl/cert.pem")
ncurl("https://www.google.com", tls = tls)

Self-Signed Certificates

For internal services or testing, generate self-signed certificates using write_cert().

# Generate self-signed certificate for testing
cert <- write_cert(cn = "127.0.0.1")

# Server TLS configuration
ser <- tls_config(server = cert$server)

# Client TLS configuration
cli <- tls_config(client = cert$client)

Use the configurations with servers and clients:

# HTTPS server
server <- http_server(
  url = "https://127.0.0.1:0",
  handlers = list(
    handler("/", function(req) list(status = 200L, body = "Secure!"))
  ),
  tls = ser
)
server$start()
server
#> < nanoServer >
#>  - url: https://127.0.0.1:50715 
#>  - state: started

# HTTPS client request
aio <- ncurl_aio(paste0(server$url, "/"), tls = cli)
while (unresolved(aio)) later::run_now(1)
#> {"args":{},"headers":{"host":"postman-echo.com","accept-encoding":"gzip, br","x-forwarded-proto":"https"},"url":"https://postman-echo.com/get"}
aio$status
#> [1] 200
aio$data
#> [1] "Secure!"

server$close()

5. Client Example: Shiny ExtendedTask

This example demonstrates using ncurl_aio() with Shiny’s ExtendedTask for non-blocking HTTP requests.

If your Shiny app calls an external API, a slow or unresponsive endpoint will block the R process and freeze the app for all users, not just the one who triggered the request. ncurl_aio() avoids this – it performs the HTTP call on a background thread and returns a promise, so the R process stays free to serve other sessions. It works anywhere that accepts a promise, including Shiny’s ExtendedTask:

library(shiny)
library(bslib)
library(nanonext)

ui <- page_fluid(
  p("The time is ", textOutput("current_time", inline = TRUE)),
  hr(),
  input_task_button("btn", "Fetch data"),

  verbatimTextOutput("result")
)

server <- function(input, output, session) {
  output$current_time <- renderText({
    invalidateLater(1000)
    format(Sys.time(), "%H:%M:%S %p")
  })

  task <- ExtendedTask$new(
    function() ncurl_aio("https://postman-echo.com/get", response = TRUE)
  ) |> bind_task_button("btn")

  observeEvent(input$btn, task$invoke())
  output$result <- renderPrint(task$result()$headers)
}

shinyApp(ui, server)

6. Server Example: Quarto Site with Dynamic API

This example shows how the unified server architecture makes it straightforward to combine HTTP or WebSocket handlers to serve different content over the same port.

If you’ve rendered a Quarto website and want to serve it locally – but also expose a dynamic API endpoint alongside it, that’s possible with a single http_server() call:

library(nanonext)

server <- http_server(
  url = "http://127.0.0.1:0",
  handlers = list(
    # Serve your rendered Quarto site
    handler_directory("/", "_site"),

    # Add a prediction API endpoint
    handler("/api/predict", function(req) {
      input <- secretbase::jsondec(req$body)
      pred <- predict(model, newdata = input)
      list(
        status = 200L,
        headers = c("Content-Type" = "application/json"),
        body = secretbase::jsonenc(list(prediction = pred))
      )
    }, method = "POST")
  )
)

server$start()
server$url
# Browse to the URL to see your Quarto site with a live API behind it

Static pages are served at native speed by NNG while the prediction endpoint is handled by R – no separate processes or ports required. Adding TLS is a single argument.