Reactive Web Framework (RWeb)

RWeb is Nostos's built-in reactive web framework that enables you to build interactive web applications with automatic UI updates. It combines reactive records, WebSocket communication, and server-side rendering to create a seamless developer experience without requiring a separate frontend framework.

What Makes RWeb Special?

  • Process per Session: Each browser session gets its own Nostos process with isolated state
  • Automatic Updates: Changes to reactive records automatically update the browser UI via WebSocket
  • Server-Side Logic: All application logic runs on the server - no JavaScript framework needed
  • Component-Based: Define reusable components that track their dependencies and re-render efficiently

Prerequisites

Before learning RWeb, make sure you understand:

Getting Started: A Simple Counter

Let's start with the classic counter example. This demonstrates the core concepts of RWeb:

use stdlib.rweb
use stdlib.rhtml

# Define reactive state - each session gets its own instance
reactive Counter = { count: Int }

# Session function - called once per browser session
session() = {
    counter = Counter(count: 0)

    # Return tuple: (render function, action handler)
    (
        # Render function - returns the UI
        () => RHtml(div([
            h1("Simple Counter"),
            p("Count: " ++ show(counter.count)),
            button("+1", dataAction: "increment"),
            button("-1", dataAction: "decrement"),
            button("Reset", dataAction: "reset")
        ])),

        # Action handler - processes user interactions
        (action, params) => match action {
            "increment" -> counter.count = counter.count + 1
            "decrement" -> counter.count = counter.count - 1
            "reset" -> counter.count = 0
            _ -> ()
        }
    )
}

main() = {
    println("Starting counter app on port 8080...")
    startRWeb(8080, "Counter App", session)
}

Run this with ./target/release/nostos counter.nos and open http://localhost:8080 in your browser.

Running RWeb Applications

RWeb provides several functions for starting servers, designed for different use cases:

Function Behavior Use Case
runRWeb Auto-detects mode: background in TUI, foreground in scripts Recommended - works everywhere
startRWeb Blocks forever serving requests Scripts that run as servers
restartRWebBackground Runs in background, auto-kills previous server TUI/REPL development with hot-reload
startRWebBackground Runs in background, returns Pid Manual process management

Smart Mode with runRWeb

The runRWeb function automatically chooses the right behavior based on context:

use stdlib.rweb
use stdlib.rhtml

reactive Counter = { count: Int }

session() = {
    counter = Counter(count: 0)
    (
        () => RHtml(div([
            h1("Counter: " ++ show(counter.count)),
            button("+1", dataAction: "inc")
        ])),
        (action, _) => match action {
            "inc" -> counter.count = counter.count + 1
            _ -> ()
        }
    )
}

# This single line works in both script and TUI/REPL!
main() = runRWeb(8080, "Counter", session)

How runRWeb Works

  • In TUI/REPL: Uses restartRWebBackground - runs in background, auto-kills previous server when you re-run
  • In scripts: Uses startRWeb - blocks and serves requests until killed
  • Detection: Uses Env.isInteractive() to check if running in TUI

Hot-Reload Development Workflow

When developing in the TUI, you can modify your code and re-run to see changes instantly:

  1. Open your RWeb file in the TUI editor
  2. Run with Ctrl+R
  3. Open http://localhost:8080 in your browser
  4. Edit code, press Ctrl+R again
  5. Refresh browser - previous server is auto-killed, new one starts

Custom Mode Detection

You can use Env.isInteractive() for custom logic:

# Check if running in TUI/REPL
main() = {
    if Env.isInteractive() then {
        println("Running in TUI - starting background server")
        pid = restartRWebBackground(8080, "Dev Server", session)
        println("Server running, pid stored for restart")
        pid
    } else {
        println("Running as script - blocking mode")
        startRWeb(8080, "Production Server", session)
    }
}

How It Works

  1. When a browser connects, RWeb spawns a new process and calls your session() function
  2. The render function is called to generate the initial HTML
  3. When the user clicks a button with dataAction, a WebSocket message is sent to the server
  4. Your action handler receives the action name and modifies the reactive record
  5. RWeb detects the change and sends an updated HTML fragment back to the browser

Understanding the Session Pattern

The session() function is the heart of an RWeb application. It returns a tuple containing two functions:

# The session function signature
session: () -> (() -> RHtml | RHtmlResult, (String, Map[String, String]) -> ())

# Breaking it down:
# 1. render: () -> RHtml | RHtmlResult
#    - Takes no arguments
#    - Returns HTML (either simple RHtml or component-based RHtmlResult)
#
# 2. handler: (action, params) -> ()
#    - action: String - the action name from dataAction/dataOnchange/etc
#    - params: Map[String, String] - parameters sent with the action
#    - Returns nothing (side effect: modifies reactive records)

The key insight is that your reactive record (like Counter) is captured in the closure. When the action handler modifies it, RWeb automatically detects the change and re-renders the affected parts of the UI.

Using Components for Efficient Updates

Simple RWeb apps re-render the entire page on every change. For larger apps, use components to update only the parts that changed:

use stdlib.rweb
use stdlib.rhtml

reactive State = { counter: Int, message: String }

session() = {
    state = State(counter: 0, message: "Hello!")

    (
        () => RHtml(div([
            h1("Component Demo"),

            # This component only re-renders when state.counter changes
            component("counter-display", () => RHtml(div([
                h3("Counter"),
                p("Value: " ++ show(state.counter)),
                button("+", dataAction: "inc"),
                button("-", dataAction: "dec")
            ]))),

            # This component only re-renders when state.message changes
            component("message-display", () => RHtml(div([
                h3("Message"),
                p(state.message),
                button("Change", dataAction: "changeMsg")
            ])))
        ])),

        (action, params) => match action {
            "inc" -> state.counter = state.counter + 1
            "dec" -> state.counter = state.counter - 1
            "changeMsg" -> state.message = "Updated at " ++ show(state.counter)
            _ -> ()
        }
    )
}

main() = startRWeb(8080, "Component Demo", session)

The component(id, renderFn) function:

  • id: A unique identifier for this component (becomes the HTML element's id)
  • renderFn: A function that returns RHtml for this component
  • Dependency tracking: RWeb tracks which reactive fields each component reads
  • Selective updates: Only components affected by a change are re-rendered

Form Handling

RWeb supports various form elements with automatic WebSocket communication. Use named parameters to configure element behavior:

Form Submission

use stdlib.rweb
use stdlib.rhtml

reactive FormData = { name: String, email: String }

session() = {
    data = FormData(name: "", email: "")

    (
        () => RHtml(div([
            h1("Registration Form"),

            # Display submitted data
            p("Name: " ++ data.name),
            p("Email: " ++ data.email),

            # The form with dataAction triggers on submit
            form([
                div([
                    label("Name: "),
                    input(inputType: "text", name: "name", placeholder: "Your name")
                ]),
                div([
                    label("Email: "),
                    input(inputType: "email", name: "email", placeholder: "you@example.com")
                ]),
                button("Submit", btnType: "submit")
            ], dataAction: "submitForm")
        ])),

        (action, params) => match action {
            "submitForm" -> {
                # Form data is automatically collected into params
                data.name = Map.get(params, "name")
                data.email = Map.get(params, "email")
            }
            _ -> ()
        }
    )
}

main() = startRWeb(8080, "Form Demo", session)

Note: When a form with dataAction is submitted, RWeb automatically collects all form field values into the params map, keyed by the input's name attribute.

Checkboxes and Radio Buttons

use stdlib.rweb
use stdlib.rhtml

reactive Prefs = { darkMode: Bool, newsletter: Bool }

session() = {
    prefs = Prefs(darkMode: false, newsletter: true)

    (
        () => RHtml(div([
            h1("Preferences"),

            p("Dark Mode: " ++ if prefs.darkMode then "ON" else "OFF"),
            p("Newsletter: " ++ if prefs.newsletter then "Subscribed" else "Unsubscribed"),

            div([
                input(inputType: "checkbox", name: "darkMode", dataOnchange: "toggleDark"),
                label(" Enable Dark Mode")
            ]),
            div([
                input(inputType: "checkbox", name: "newsletter", dataOnchange: "toggleNews"),
                label(" Subscribe to Newsletter")
            ])
        ])),

        (action, params) => match action {
            "toggleDark" -> {
                # params.checked is "true" or "false" for checkboxes
                checked = Map.get(params, "checked")
                prefs.darkMode = checked == "true"
            }
            "toggleNews" -> {
                checked = Map.get(params, "checked")
                prefs.newsletter = checked == "true"
            }
            _ -> ()
        }
    )
}

main() = startRWeb(8080, "Checkbox Demo", session)

Select Dropdowns

use stdlib.rweb
use stdlib.rhtml

reactive Selection = { country: String }

session() = {
    sel = Selection(country: "")

    (
        () => RHtml(div([
            h1("Country Selector"),
            p("Selected: " ++ if sel.country == "" then "None" else sel.country),

            select([
                option("Choose a country...", value: ""),
                option("United States", value: "us"),
                option("United Kingdom", value: "uk"),
                option("Canada", value: "ca"),
                option("Australia", value: "au")
            ], name: "country", dataOnchange: "countryChanged")
        ])),

        (action, params) => match action {
            "countryChanged" -> sel.country = Map.get(params, "value")
            _ -> ()
        }
    )
}

main() = startRWeb(8080, "Select Demo", session)

Real-Time Input (oninput)

For instant feedback as the user types, use dataOninput instead of dataOnchange:

use stdlib.rweb
use stdlib.rhtml

reactive Search = { query: String }

session() = {
    search = Search(query: "")

    (
        () => RHtml(div([
            h1("Live Search"),
            input(inputType: "text", placeholder: "Type to search...", dataOninput: "search"),
            p("Searching for: " ++ search.query),
            p("Results: " ++ show(String.length(search.query)) ++ " characters typed")
        ])),

        (action, params) => match action {
            "search" -> search.query = Map.get(params, "value")
            _ -> ()
        }
    )
}

main() = startRWeb(8080, "Live Search", session)

Event Attributes Reference

Attribute Elements Params Sent Use Case
dataAction button, a, div, form Click: data-param-* attrs
Form: all input values
Buttons, links, form submission
dataOnchange input, select, textarea value, checked (for checkbox/radio) Form field changes (on blur or selection)
dataOninput input, textarea value Real-time input feedback (every keystroke)

Form Validation

RWeb supports a layered validation approach for robust form handling:

HTML5 Client-Side Validation

Form inputs support HTML5 validation attributes for instant client-side feedback:

# HTML5 validation attributes on form inputs
form([
    # Required field with minimum length
    input(inputType: "text", name: "username", required: "true", minLength: "3", maxLength: "20"),

    # Email validation
    input(inputType: "email", name: "email", required: "true"),

    # Number with range
    input(inputType: "number", name: "age", min: "18", max: "120"),

    # Pattern matching (regex)
    input(inputType: "text", name: "phone", pattern: "[0-9]{10}", placeholder: "10 digits"),

    button("Submit", btnType: "submit")
], dataAction: "register")

Tip: Use novalidate: "novalidate" on the form to disable HTML5 validation for server-side only validation.

Server-Side Validation

For defense in depth, use the stdlib.validation library for server-side validation:

use stdlib.validation.{validate, required, minLength, maxLength, email, minValue, maxValue, Valid, Invalid, firstError}

# Define validation rules
makeRules() = {
    r1 = Map.insert(%{}, "username", [required(), minLength(3), maxLength(20)])
    r2 = Map.insert(r1, "email", [required(), email()])
    Map.insert(r2, "age", [required(), minValue(18), maxValue(120)])
}

# In your action handler:
(action, params) => match action {
    "register" -> {
        result = validate(params, makeRules())
        match result {
            Valid(data) -> {
                state.success = true
                state.username = Map.get(data, "username")
            }
            Invalid(errors) -> {
                state.error = firstError(result)
            }
        }
    }
    _ -> ()
}

Available Validators

Validator Description
required() Field must not be empty
minLength(n) Minimum string length
maxLength(n) Maximum string length
email() Must contain @ and .
minValue(n) Minimum numeric value
maxValue(n) Maximum numeric value
range(min, max) Value must be within range
matchesField(field, params) Must match another field (e.g., password confirmation)
custom(predicate, msg) Custom validation with predicate function

Multi-Route Applications

For applications with multiple pages, use startRWebWithRoutes with resolver functions:

use stdlib.rweb.{startRWebWithRoutes}
use stdlib.rhtml.{div, h1, p, button, a}
use stdlib.server.{respondHtml, respondJson}

reactive HomeState = { count: Int }
reactive DashState = { total: Int }

# Reactive session for home page
# Receives writerId (for external push) and cookies from HTTP request
homeSession(writerId, cookies) = {
    state = HomeState(count: 0)
    (
        () => RHtml(div([
            h1("Home"),
            p("Counter: " ++ show(state.count)),
            button("+", dataAction: "inc"),
            a("Go to Dashboard", href: "/dashboard")
        ])),
        (action, _) => match action {
            "inc" -> state.count = state.count + 1
            _ -> ()
        }
    )
}

# Reactive session for dashboard
dashSession(writerId, cookies) = {
    state = DashState(total: 100)
    (
        () => RHtml(div([
            h1("Dashboard"),
            p("Total: $" ++ show(state.total)),
            button("Add $10", dataAction: "add10"),
            a("Go to Home", href: "/")
        ])),
        (action, _) => match action {
            "add10" -> state.total = state.total + 10
            _ -> ()
        }
    )
}

# Reactive resolver: path -> Option[sessionFn]
reactiveResolver(path) = match path {
    "/" -> Some(homeSession)
    "/dashboard" -> Some(dashSession)
    _ -> None()
}

# Plain resolver: path -> Option[handler]
# For static HTML, API endpoints, etc.
plainResolver(path) = match path {
    "/about" -> Some(req => respondHtml(req, "<h1>About Us</h1>"))
    "/api/health" -> Some(req => respondJson(req, "{\"status\":\"ok\"}"))
    _ -> None()
}

main() = {
    println("Starting multi-route app on port 8080...")
    startRWebWithRoutes(8080, "Multi-Route App", reactiveResolver, plainResolver)
}

Resolver Functions

  • reactiveResolver(path) -> Option[(writerId, cookies) -> Session] - Returns a session setup function
  • plainResolver(path) -> Option[Request -> ()] - Returns a plain HTTP handler
  • Session functions receive writerId (Int) and cookies (List[(String, String)])
  • Return None() for paths you don't handle

Static File Serving

For applications that need to serve CSS, JavaScript, images, and other static assets, use startRWebFull:

use stdlib.rweb.{startRWebFull}
use stdlib.rhtml.{div, h1, link, script, img}
use stdlib.server.{respondJson}

reactive AppState = { theme: String }

appSession(writerId, cookies) = {
    state = AppState(theme: "light")
    (
        () => RHtml(div([
            # Link to static CSS file
            link(href: "/static/css/style.css", rel: "stylesheet"),

            h1("My App"),
            img(src: "/static/images/logo.png", alt: "Logo"),
            p("Current theme: " ++ state.theme),
            button("Toggle Theme", dataAction: "toggle"),

            # Link to static JavaScript
            script(src: "/static/js/app.js")
        ])),
        (action, _) => match action {
            "toggle" -> state.theme = if state.theme == "light" then "dark" else "light"
            _ -> ()
        }
    )
}

reactiveResolver(path) = match path {
    "/" -> Some(appSession)
    _ -> None()
}

plainResolver(path) = match path {
    "/api/status" -> Some(req => respondJson(req, "{\"ok\":true}"))
    _ -> None()
}

# Static file configuration: list of (urlPrefix, directory) tuples
staticConfig = [
    ("/static", "./public"),      # /static/css/style.css -> ./public/css/style.css
    ("/assets", "./assets")       # /assets/logo.png -> ./assets/logo.png
]

main() = {
    startRWebFull(8080, "My App", reactiveResolver, plainResolver, staticConfig)
}

Request Routing Order

When a request comes in, RWeb checks handlers in this order:

  1. Reactive pages - WebSocket-enabled pages with live updates
  2. Plain handlers - API endpoints, static HTML pages
  3. Static files - CSS, JS, images from configured directories
  4. 404 fallback - If nothing matches

Supported MIME Types

Static files are served with correct Content-Type headers:

  • Text: .html, .css, .js, .json, .svg, .txt, .xml
  • Images: .png, .jpg, .jpeg, .gif, .webp, .ico
  • Fonts: .woff, .woff2, .ttf, .otf, .eot
  • Media: .mp3, .mp4, .webm
  • Other: .pdf, .zip, .wasm

Cookie Handling

RWeb passes cookies from the HTTP request to your session setup function. This enables authentication flows and persistent user preferences:

use stdlib.rweb.{startRWebWithRoutes}
use stdlib.rhtml.{div, h1, p, button, form, input}
use stdlib.server.{getCookie, hasCookie, setCookie, setCookieWithAge, clearCookie, respondHtmlWith, redirect, redirectWith}

reactive UserState = { username: String, loggedIn: Bool }

# Check authentication on session start
appSession(writerId, cookies) = {
    # Read session token from cookies
    sessionToken = getCookie(cookies, "session")
    isLoggedIn = sessionToken != ""

    state = UserState(
        username: if isLoggedIn then "User" else "",
        loggedIn: isLoggedIn
    )

    (
        () => RHtml(div([
            h1("Welcome!"),
            if state.loggedIn then
                div([
                    p("Hello, " ++ state.username),
                    button("Logout", dataAction: "logout")
                ])
            else
                form([
                    input(inputType: "text", name: "username", placeholder: "Username"),
                    button("Login", btnType: "submit")
                ], dataAction: "login")
        ])),
        (action, params) => match action {
            "login" -> {
                state.username = Map.get(params, "username")
                state.loggedIn = true
            }
            "logout" -> {
                state.loggedIn = false
                state.username = ""
            }
            _ -> ()
        }
    )
}

reactiveResolver(path) = match path {
    "/" -> Some(appSession)
    _ -> None()
}

# Plain resolver for login/logout that sets cookies
plainResolver(path) = match path {
    "/auth/login" -> Some(req => {
        # In real app: validate credentials, create session
        cookie = setCookieWithAge("session", "abc123", 86400)  # 24 hours
        redirectWith(req, "/", [cookie])
    })
    "/auth/logout" -> Some(req => {
        cookie = clearCookie("session")
        redirectWith(req, "/", [cookie])
    })
    _ -> None()
}

main() = startRWebWithRoutes(8080, "Auth Demo", reactiveResolver, plainResolver)

Cookie Helper Functions (from stdlib.server)

  • getCookie(cookies, name) - Get cookie value (returns "" if not found)
  • hasCookie(cookies, name) - Check if cookie exists
  • setCookie(name, value) - Create Set-Cookie header (HttpOnly, Path=/)
  • setCookieWithAge(name, value, maxAge) - With max age in seconds
  • setSecureCookie(name, value, maxAge) - HTTPS only with SameSite=Strict
  • clearCookie(name) - Delete cookie (Max-Age=0)

Setting Cookies via WebSocket

For simpler flows, you can set cookies directly from within your reactive action handler using rwebSetCookie. This sends a WebSocket message that tells the browser to set the cookie via JavaScript:

use stdlib.rweb.{startRWeb, rwebSetCookie, rwebClearCookie}
use stdlib.rhtml.{div, h1, p, button, form, input}
use stdlib.server.{getCookie}

reactive AppState = { username: String, loggedIn: Bool }

appSession(writerId, cookies) = {
    # Read cookie on page load
    savedUser = getCookie(cookies, "user")
    state = AppState(
        username: if savedUser != "" then savedUser else "",
        loggedIn: savedUser != ""
    )

    (
        () => RHtml(div([
            h1("Login Demo"),
            if state.loggedIn then
                div([
                    p("Hello, " ++ state.username ++ "!"),
                    button("Logout", dataAction: "logout")
                ])
            else
                form([
                    input(inputType: "text", name: "username", placeholder: "Username"),
                    button("Login", btnType: "submit")
                ], dataAction: "login")
        ])),
        (action, params) => match action {
            "login" -> {
                username = Map.get(params, "username")
                # Set cookie via WebSocket - browser executes document.cookie
                rwebSetCookie(writerId, "user", username, 3600)  # 1 hour
                state.username = username
                state.loggedIn = true
            }
            "logout" -> {
                # Clear cookie via WebSocket
                rwebClearCookie(writerId, "user")
                state.username = ""
                state.loggedIn = false
            }
            _ -> ()
        }
    )
}

main() = startRWeb(8080, "Login Demo", appSession)

WebSocket Cookie Functions (from stdlib.rweb)

  • rwebSetCookie(writerId, name, value, maxAge) - Set cookie (maxAge in seconds)
  • rwebSetSecureCookie(writerId, name, value, maxAge) - HTTPS with SameSite=Strict
  • rwebClearCookie(writerId, name) - Delete cookie

Security Note

Cookies set via rwebSetCookie are not HttpOnly (they're set via JavaScript). For sensitive authentication tokens, use the HTTP redirect approach with setCookieWithAge instead, which creates HttpOnly cookies that JavaScript cannot access.

Complete Secure Auth Example

For a production-ready authentication pattern with HttpOnly cookies and server-side sessions, see:

examples/rweb_secure_auth.nos

Features: HttpOnly cookies, server-side session storage, login/logout flow, protected routes, credential validation.

Passing Data via Action Parameters

You can pass data from buttons using data-param-* attributes:

use stdlib.rweb
use stdlib.rhtml

reactive State = { selected: String }

session() = {
    state = State(selected: "none")

    (
        () => RHtml(div([
            h1("Item Selector"),
            p("Selected: " ++ state.selected),

            # Each button passes its item ID
            button("Apple", dataAction: "select", attrs: [("data-param-item", "apple")]),
            button("Banana", dataAction: "select", attrs: [("data-param-item", "banana")]),
            button("Cherry", dataAction: "select", attrs: [("data-param-item", "cherry")])
        ])),

        (action, params) => match action {
            "select" -> state.selected = Map.get(params, "item")
            _ -> ()
        }
    )
}

main() = startRWeb(8080, "Item Selector", session)

Complete Example: Todo Application

Here's a complete todo application demonstrating multiple reactive records, components, and various interactions:

use stdlib.rweb
use stdlib.rhtml
use stdlib.list

reactive Todo = { id: Int, text: String, done: Bool }
reactive AppState = {
    todos: List[Todo],
    nextId: Int,
    filter: String  # "all", "active", "done"
}

# Helper to create a todo item element
todoItem(todo: Todo) = div([
    span(
        todo.text ++ if todo.done then " [DONE]" else "",
        class: if todo.done then "line-through text-slate-500" else ""
    ),
    button("Toggle", dataAction: "toggle", attrs: [("data-param-id", show(todo.id))]),
    button("Delete", dataAction: "delete", attrs: [("data-param-id", show(todo.id))])
], class: "flex gap-2 items-center p-2 border-b border-slate-700")

session() = {
    state = AppState(todos: [], nextId: 1, filter: "all")

    # Filter todos based on current filter setting
    filteredTodos() = match state.filter {
        "active" -> state.todos.filter(t => !t.done)
        "done" -> state.todos.filter(t => t.done)
        _ -> state.todos
    }

    (
        () => RHtml(div([
            h1("Todo App"),

            # Add todo form
            form([
                input(inputType: "text", name: "text", placeholder: "What needs to be done?"),
                button("Add", btnType: "submit")
            ], dataAction: "addTodo"),

            # Stats component
            component("stats", () => RHtml(div([
                p("Total: " ++ show(List.length(state.todos))),
                p("Done: " ++ show(List.length(state.todos.filter(t => t.done))))
            ]))),

            # Filter buttons
            div([
                button("All", dataAction: "setFilter", attrs: [("data-param-filter", "all")]),
                button("Active", dataAction: "setFilter", attrs: [("data-param-filter", "active")]),
                button("Done", dataAction: "setFilter", attrs: [("data-param-filter", "done")]),
                p("Filter: " ++ state.filter)
            ]),

            # Todo list component
            component("todo-list", () => RHtml(div(
                filteredTodos().map(todoItem)
            )))
        ])),

        (action, params) => match action {
            "addTodo" -> {
                text = Map.get(params, "text")
                if String.length(text) > 0 then {
                    newTodo = Todo(id: state.nextId, text: text, done: false)
                    state.todos = state.todos ++ [newTodo]
                    state.nextId = state.nextId + 1
                } else ()
            }
            "toggle" -> {
                id = parseInt(Map.get(params, "id"))
                state.todos = state.todos.map(t =>
                    if t.id == id then { t.done = !t.done; t } else t
                )
            }
            "delete" -> {
                id = parseInt(Map.get(params, "id"))
                state.todos = state.todos.filter(t => t.id != id)
            }
            "setFilter" -> state.filter = Map.get(params, "filter")
            _ -> ()
        }
    )
}

main() = {
    println("Starting Todo app on port 8080...")
    startRWeb(8080, "Todo App", session)
}

Best Practices

1. Use Components for Large UIs

Wrap sections of your UI in component() to enable targeted updates. Each component tracks its dependencies and only re-renders when those specific reactive fields change.

2. Keep Action Handlers Simple

Action handlers should just update state. Put complex logic in helper functions. The handler runs synchronously, so keep it fast.

3. Use Meaningful Component IDs

Component IDs become HTML element IDs. Use descriptive names like "user-profile" or "cart-summary" rather than generic names.

4. Separate Concerns with Multiple Records

Use separate reactive records for different parts of your state. This makes dependencies clearer and can improve update efficiency.

External Push: Server-Side Updates

RWeb supports pushing updates to connected clients from external processes. This is useful for:

  • Real-time notifications from background workers
  • Broadcasting updates to specific sessions
  • Integrating with external systems (message queues, webhooks, etc.)

How It Works

When a client connects, RWeb splits the WebSocket into separate read/write handles. The writerId is logged and can be used by any process to push messages:

[RWeb] Session started: pid=<pid 2> writerId=123

Push Functions

Three helper functions are available for pushing content:

# Push a full page update (replaces entire rweb-root content)
rwebPushFull(writerId, "<h1>New Content</h1>")

# Push a component update (updates specific component by ID)
rwebPushComponent(writerId, "notifications", "<span>You have 3 new messages</span>")

# Push raw JSON message (for custom client-side handling)
rwebPushRaw(writerId, "{\"type\":\"custom\",\"data\":\"hello\"}")

Example: Background Notification Pusher

Here's an example that sends notifications to a connected session from a background process:

use stdlib.rweb
use stdlib.rhtml

# Background worker that pushes notifications
notificationWorker(writerId) = {
    sleep(5000)  # Wait 5 seconds
    rwebPushComponent(writerId, "alerts",
        "<div class='alert'>New notification from server!</div>")
    notificationWorker(writerId)  # Loop forever
}

# Main session with an alerts component
session() = {
    state = State(message: "Waiting...")
    (
        () => RHtml(div([
            h1("Push Demo"),
            component("alerts", () => RHtml(
                div("No alerts yet", class: "alerts")
            )),
            p(state.message)
        ])),
        (action, params) => ()
    )
}

# Note: In practice, you'd capture writerId from session logs
# or pass it via inter-process communication

📁 Full Example

See examples/rweb_external_push.nos for a complete working example demonstrating external push capabilities.

Low-Level API

For advanced use cases, you can use the WebSocket builtins directly:

# Split a WebSocket into read/write handles
splitResult = WebSocket.split(ws)
requestId = splitResult.requestId   # For receiving messages
writerId = splitResult.writerId     # For sending (shareable)

# Send via shared writer (thread-safe, can be called from any process)
WebSocket.sendShared(writerId, "Hello from another process!")

Testing RWeb Applications

RWeb applications can be tested using the Selenium WebDriver bindings. See the Selenium chapter for details. Here's a quick example:

# test_counter.nos - Test for the counter app
main() = {
    driver = Selenium.connect("http://localhost:4444")
    Selenium.goto(driver, "http://localhost:8080")
    Selenium.waitFor(driver, "button", 5000)

    # Test initial state
    text = Selenium.text(driver, "p")
    assert(String.contains(text, "Count: 0"))

    # Test increment
    Selenium.click(driver, "button")  # Click +1
    sleep(500)
    text = Selenium.text(driver, "p")
    assert(String.contains(text, "Count: 1"))

    Selenium.close(driver)
    println("All tests passed!")
    0
}