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] "Wed, 25 Feb 2026 20:10:29 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] "Wed, 25 Feb 2026 20:10:30 GMT"
#>
#> $headers$`Content-Type`
#> [1] "application/json; charset=utf-8"
#>
#> $headers$`Content-Length`
#> [1] "143"
#>
#> $headers$Connection
#> [1] "close"
#>
#> $headers$`CF-RAY`
#> [1] "9d39d8b0eb7bce7b-LHR"
#>
#> $headers$etag
#> [1] "W/\"8f-7zN8nSad8A9WlFJjKQZB04z5nHE\""
#>
#> $headers$vary
#> [1] "Accept-Encoding"
#>
#> $headers$`Set-Cookie`
#> [1] "sails.sid=s%3AwH622ngshSXMrA7ebK36VF8ljdvJURS_.dc0udVn4RC4LtE%2BuLRsSPSguz4JiRmp2ko1ecmMaxWI; Path=/; HttpOnly, __cf_bm=sijEg6f0LkDsu4.vo_d65ThwUaephl5UJTmhqTqHOxQ-1772050230-1.0.1.1-_7tgVpEYqMqBiaCocz16tASo_t.71RsyJlO74Q_tW33JvUS8gP0H.n0XYNVQ6IWXf9bHQEE2ClHqnRCDrReRcrgzZcYD.im_JDYn_fB6O1g; path=/; expires=Wed, 25-Feb-26 20:40:30 GMT; domain=.postman-echo.com; HttpOnly; Secure, _cfuvid=yjuM4wzOZHEA..Tmr5h3VMYGOY6fe8afDDwByo9TcZg-1772050230010-0.0.1.1-604800000; path=/; domain=.postman-echo.com; HttpOnly; Secure; SameSite=None"
#>
#> $headers$`x-envoy-upstream-service-time`
#> [1] "4"
#>
#> $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] "Wed, 25 Feb 2026 20:10:30 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\"}"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] "Wed, 25 Feb 2026 20:10:30 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
#> [26] 73 74 22 3a 22 70 6f 73 74 6d 61 6e 2d 65 63 68 6f 2e 63 6f 6d 22 2c 22 61
#> [51] 63 63 65 70 74 2d 65 6e 63 6f 64 69 6e 67 22 3a 22 67 7a 69 70 2c 20 62 72
#> [76] 22 2c 22 78 2d 66 6f 72 77 61 72 64 65 64 2d 70 72 6f 74 6f 22 3a 22 68 74
#> [101] 74 70 73 22 2c 22 63 6f 6e 74 65 6e 74 2d 74 79 70 65 22 3a 22 61 70 70 6c
#> [126] 69 63 61 74 69 6f 6e 2f 6a 73 6f 6e 22 7d 2c 22 75 72 6c 22 3a 22 68 74 74
#> [151] 70 73 3a 2f 2f 70 6f 73 74 6d 61 6e 2d 65 63 68 6f 2e 63 6f 6d 2f 67 65 74
#> [176] 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.
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, req) {
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:
-
ws$send(data)- Send a message to the client -
ws$close()- Close the connection -
ws$id- Unique integer connection identifier
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:
-
conn$send(data)- Send a data chunk -
conn$close()- Close the connection -
conn$set_status(code)- Set HTTP status (before first send) -
conn$set_header(name, value)- Set response header (before first send) -
conn$id- Unique connection identifier
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:
- Linux:
/etc/ssl/certs/ca-certificates.crtor/etc/pki/tls/certs/ca-bundle.crt - macOS:
/etc/ssl/cert.pem - Windows: download from the Common CA Database site run by Mozilla (select the Server Authentication SSL/TLS certificates text file). This link is not endorsed; use at your own risk.
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:50424
#> - 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 itStatic 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.
