# 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] "Mon, 30 Mar 2026 22:16:56 GMT"
    #>
    #>
    #> $data
    #> [1] "{\"args\":{},\"data\":{\"key\":\"value\"},\"files\":{},\"form\":{},\"headers\":{\"host\":\"postman-echo.com\",\"accept-encoding\":\"gzip, br\",\"content-length\":\"16\",\"authorization\":\"Bearer APIKEY\",\"content-type\":\"application/json\",\"x-forwarded-proto\":\"https\"},\"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] "Mon, 30 Mar 2026 22:16:56 GMT"
    #>
    #> $headers$`Content-Type`
    #> [1] "application/json; charset=utf-8"
    #>
    #> $headers$`Content-Length`
    #> [1] "143"
    #>
    #> $headers$Connection
    #> [1] "close"
    #>
    #> $headers$etag
    #> [1] "W/\"8f-7zN8nSad8A9WlFJjKQZB04z5nHE\""
    #>
    #> $headers$vary
    #> [1] "Accept-Encoding"
    #>
    #> $headers$`x-envoy-upstream-service-time`
    #> [1] "5"
    #>
    #> $headers$`cf-cache-status`
    #> [1] "DYNAMIC"
    #>
    #> $headers$`Set-Cookie`
    #> [1] "sails.sid=s%3AtfbN2TazlwshVuIc_Y-l_CKCt7WScK3s.5Z30lz615FemZ1kCseuVIUD4G%2BEAGJfIiYaJhfFDiGU; Path=/; HttpOnly, __cf_bm=mX8EtQ27DKZ2X6uAi8_krT8AN00Bf9rEnI4aauRi_.Q-1774909016.466189-1.0.1.1-Wtzs3qUkHi8sLuARpkSeFbJ8vXysEL7bXJdqKa3EqeWEanTLfzrcxvOudXHbpT8moKuZq5KuQXmZr0dSvqRdXUuc_yS8Ms47TY9OL3jb0muL_3gxejNDW7cDvtDcmLKh; HttpOnly; Secure; Path=/; Domain=postman-echo.com; Expires=Mon, 30 Mar 2026 22:46:56 GMT, _cfuvid=PgOu7XWt97qqSBedwhgeqXxkMVIYi18WzlBWroe865k-1774909016.466189-1.0.1.1-z9Y3X8DXI.zgMtblcU7939qcoMyvq2dQUGtA9Mbi59o; HttpOnly; SameSite=None; Secure; Path=/; Domain=postman-echo.com"
    #>
    #> $headers$Server
    #> [1] "cloudflare"
    #>
    #> $headers$`CF-RAY`
    #> [1] "9e4a7b48ed31cd3a-LHR"
    #>
    #>
    #> $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] "Mon, 30 Mar 2026 22:16:56 GMT"

    res$status
    #> [1] 200

    res$data
    #> [1] "{\"args\":{},\"data\":{\"async\":true},\"files\":{},\"form\":{},\"headers\":{\"host\":\"postman-echo.com\",\"accept-encoding\":\"gzip, br\",\"content-type\":\"application/json\",\"content-length\":\"15\",\"x-forwarded-proto\":\"https\"},\"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] "Mon, 30 Mar 2026 22:16:57 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 63
    #>  [51] 6f 6e 74 65 6e 74 2d 74 79 70 65 22 3a 22 61 70 70 6c 69 63 61 74 69 6f 6e
    #>  [76] 2f 6a 73 6f 6e 22 2c 22 78 2d 66 6f 72 77 61 72 64 65 64 2d 70 72 6f 74 6f
    #> [101] 22 3a 22 68 74 74 70 73 22 2c 22 61 63 63 65 70 74 2d 65 6e 63 6f 64 69 6e
    #> [126] 67 22 3a 22 67 7a 69 70 2c 20 62 72 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.

    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$serve()

`$serve()` is a blocking call that starts the server, runs the event loop to process requests, and automatically closes the server on interrupt (Ctrl+C). For non-blocking use, call `$start()` and `$close()` separately, with `repeat later::run_now(Inf)` to run the event loop manually.

Specifying port `0` in the URL lets the OS assign an available port. The actual port is reflected in `server$url` after `$start()` or `$serve()`, 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$serve()

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$serve()

#### 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.crt` or `/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:55055
    #>  - 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.