API Reference

Signals

create_signal(initial)

Create a signal. Returns (getter, setter) tuple. Reading the getter inside effects/memos tracks it as a dependency. Supports Int64, Bool, Float64, and String — each stored as the appropriate WASM type.

# Integer signal — WASM i64 global
count, set_count = create_signal(0)

# Bool signal — WASM i32 global
active, set_active = create_signal(true)

# Float64 signal — WASM f64 global
temp, set_temp = create_signal(98.6)

# String signal — WasmGC ref global
query, set_query = create_signal("")

create_effect(() -> ...)

Run a side effect whenever its signal dependencies change. Effects are owner-scoped — they are disposed when their parent owner is cleaned up. Use js() for browser APIs like console.log.

create_effect(() -> js("console.log('count:', $1)", count()))
# Runs immediately + re-runs on every count() change
# Disposed automatically when the owning scope is cleaned up

create_memo(() -> ...)

Create a cached derived value. Recomputes only when dependencies change. Memo closures compile to WASM. Return type can be Int, String, or Vector{String} — reference types use WasmGC.

# Int memo — cached as i64
doubled = create_memo(() -> count() * 2)
doubled()  # read derived value — cached until count() changes

# String/Vector memo — cached as WasmGC refs
filtered = create_memo(() -> begin
    q = lowercase(query())
    result = String[]
    for i in 1:length(items)
        if startswith(lowercase(items[i]), q)
            push!(result, items[i])
        end
    end
    result
end)

batch(() -> ...)

Defer effect execution until all signal writes complete. DOM event handlers are auto-batched. Use explicitly in setTimeout or async code.

# Handlers are auto-batched — effects run once, not twice:
:on_click => () -> begin
    set_name("Alice")   # deferred
    set_count(count() + 1)  # deferred
end  # effects run here (once)

# Manual batch for async/timer code:
batch(() -> begin
    set_a(1)
    set_b(2)
end)

on_mount(() -> ...)

Run a function once after the component mounts to the DOM. Unlike create_effect, this does NOT track dependencies and never re-runs. Registered with the current owner. Use for one-time initialization: DOM refs, third-party libraries, focus management.

on_mount() do
    js("document.getElementById('my-input').focus()")
end

# Also useful for loading external scripts, initializing charts, etc.

on_cleanup(() -> ...)

Register a cleanup function with the current owner. Called when the owner scope is disposed (e.g., when a For item is removed or a Show branch toggles). Use for teardown: removing event listeners, clearing timers.

on_cleanup() do
    js("clearInterval($1)", timer_id())
end

Control Flow

Show(condition; fallback=...) do ... end

Conditional rendering. Content is actually inserted/removed from the DOM, not hidden with CSS. Condition can be a bare signal getter or a closure (closures compile to WASM). Optional fallback renders when condition is false.

visible, set_visible = create_signal(1)

# Bare signal getter as condition
Show(visible) do
    P("I exist in the DOM!")
end

# Closure condition — compiled to WASM
Show(() -> count() > 5) do
    P("Count is above 5!")
end

# With fallback
Show(visible; fallback=P("Nothing to show.")) do
    P("Content is visible!")
end

For(items) do item, idx ... end

List rendering with keyed reconciliation — reuses DOM nodes for items that persist across updates. Each item gets its own owner scope; effects and cleanups are disposed when the item is removed. Supports nested For for 2D data (tables, grids).

items, set_items = create_signal(["a", "b", "c"])

Ul(For(items) do item, idx
    Li(item)
end)

# Nested For (table rows × cells)
Table(Tbody(For(rows) do row
    Tr(For(row) do cell; Td(cell); end)
end))

Components

function Name(args...) ... end

A plain Julia function that returns VNodes is an SSR component. Runs at build time with full access to Julia packages. No macro needed — just return elements.

using DataFrames: DataFrame, names, eachrow

function DataTable()
    df = DataFrame(Name=["Alice","Bob"], Age=[28,35])
    cols = names(df)
    rows = [string.(collect(row)) for row in eachrow(df)]
    return Table(
        Thead(Tr(For(cols) do col; Th(col); end)),
        Tbody(For(rows) do row; Tr(For(row) do c; Td(c); end); end)
    )
end

@island function Name(; kwargs...) ... end

Mark a component as interactive. Handler and memo closures compile to WebAssembly via WasmTarget.jl. Browser APIs use js() wired as WASM imports. Kwargs must be typed — they become JSON-serializable props.

@island function Counter(; initial::Int = 0)
    count, set_count = create_signal(initial)
    doubled = create_memo(() -> count() * 2)
    create_effect(() -> js("console.log('count:', $1)", count()))

    return Div(
        Button(:on_click => () -> set_count(count() + 1), "+"),
        Span(count),
        Span("doubled: ", doubled)
    )
end

js(code::String, args...)

Escape hatch — call JavaScript from WASM via imports. Use $1, $2 to interpolate signal/memo values. In Julia, js() is a no-op. In the browser, the string runs as JS.

# DOM manipulation
js("document.documentElement.classList.toggle('dark')")

# Logging with signal values
js("console.log('count:', $1, 'doubled:', $2)", count(), doubled())

# localStorage with shared variables
js("localStorage.setItem('key', $1)", count())

Routing

Therapy.jl uses two routing systems: file-based routing for defining pages at build time, and client-side navigation (Astro View Transitions pattern) for smooth page transitions without full reloads.

File-Based Routing

Pages are Julia files in routes/. Each file exports a function that returns VNodes. The file path determines the URL. This runs at build time — every page is pre-rendered to static HTML.

# File structure --> URLs
routes/
  index.jl          # --> /
  about.jl          # --> /about
  getting-started.jl # --> /getting-started
  examples/
    index.jl        # --> /examples
    advanced.jl     # --> /examples/advanced

# Each file is a function returning VNodes:
# routes/about.jl
() -> begin
    Div(
        H1("About"),
        P("This page is server-rendered at build time.")
    )
end

Client Navigation (View Transitions)

After the first page loads, clicking internal links does NOT trigger a full page reload. Instead, the client router (same pattern as Astro View Transitions) intercepts the click, fetches the next page via fetch(), and swaps the content using the browser's document.startViewTransition() API. Islands on the new page re-hydrate automatically.

# How it works (automatic — no code needed):
#
# 1. User clicks <a href="/examples/">
# 2. Router intercepts click (prevents full reload)
# 3. fetch("/examples/") gets the HTML
# 4. document.startViewTransition() animates the swap
# 5. <head> is diffed (title, meta tags updated)
# 6. <body> content is swapped
# 7. <therapy-island> components re-hydrate
# 8. URL updated via history.pushState()
#
# Back/forward buttons work via popstate listener.
# Older browsers fall back to instant swap (no animation).

This is NOT an SPA router — there is no client-side route table and no JS bundle containing all pages. Each page is independently pre-built HTML. The router just makes transitions smooth.

Nav Links

Use data-navlink on links to get automatic active styling. The router adds/removes CSS classes based on the current URL.

# Active link styling (automatic)
A(:href => "/examples",
  "data-navlink" => "true",
  "data-active-class" => "text-accent-600 font-bold",
  "data-inactive-class" => "text-warm-500",
  "Examples")

# Exact match (only active on exact path, not children)
A(:href => "/",
  "data-navlink" => "true",
  "data-exact" => "true",
  "Home")

Links & Anchor Scrolling

Internal links are intercepted by the router for smooth navigation. Hash links (href="#section") are NOT intercepted — the browser scrolls natively. This follows the Astro pattern. Use :id on heading elements to create scroll targets.

# Internal page link — router intercepts, fetches, swaps
A(:href => "/examples", "Go to examples")

# Hash anchor — browser scrolls natively (no router)
A(:href => "#signals", "Jump to Signals")

# External link — opens in new tab, router ignores
A(:href => "https://github.com/...", :target => "_blank", "GitHub")

# Heading with scroll target
H2(:id => "signals", "Signals")

Therapy.jl does not use <base href> (it breaks hash links). All URLs are prefixed with the base path at build time, same as Astro.

HTML Elements

All standard HTML elements are available as capitalized functions. Props use :symbol => value syntax. Event handlers use :on_click, :on_input, etc.

Div(:class => "container",
    H1("Hello"),
    Button(:on_click => () -> set_count(count() + 1), "Click me"),
    Input(:type => "range", :value => freq, :on_input => set_freq),
    A(:href => "https://example.com", "Link")
)

Div, Span, P, A, Button, Input, Form, Label, H1–H6, Strong, Em, Code, Pre, Ul, Ol, Li, Table, Thead, Tbody, Tr, Th, Td, Header, Footer, Nav, MainEl, Section, Article, Img, Svg, ...

Middleware

Higher-order function middleware pipeline ported from Oxygen.jl. Each middleware wraps a handler: handler -> (req -> response). Composed via reduce(|>).

compose_middleware(handler, middleware)

Compose a base handler with a middleware pipeline. First middleware in the vector is outermost (runs first on request, last on response).

function my_middleware(handler)
    return function(req::HTTP.Request)
        # pre-processing
        response = handler(req)
        # post-processing
        return response
    end
end

pipeline = compose_middleware(base_handler, [mw1, mw2, mw3])
# Execution: mw1 -> mw2 -> mw3 -> handler -> mw3 -> mw2 -> mw1

CorsMiddleware(; kwargs...)

CORS middleware. Handles OPTIONS preflight requests and adds CORS headers to all responses.

cors = CorsMiddleware(
    allowed_origins=["https://myapp.com"],
    allowed_headers=["*"],
    allowed_methods=["GET", "POST", "OPTIONS"],
    allow_credentials=true,
    max_age=86400
)

RateLimiterMiddleware(; kwargs...)

Fixed-window rate limiter per client IP. Returns 429 when exceeded. Sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After headers.

limiter = RateLimiterMiddleware(rate_limit=100, window=60)

BearerAuthMiddleware(validate_token; header, scheme)

Bearer token authentication. Extracts token from Authorization header, calls validate_token(token). Returns 401 if missing/invalid. Stores user info in req.context[:user].

validate(token) = token == "secret" ? Dict("role" => "admin") : nothing
auth = BearerAuthMiddleware(validate)

# Use with App
app = App(middleware=[CorsMiddleware(), auth])

API Routes

JSON API endpoints with path parameters, body parsing, and per-route middleware. Adapted from Oxygen.jl's route registration pattern.

create_api_router(routes)

Create a request handler from route definitions. Each route maps HTTP methods to handlers. Handlers receive (req, params) and return data (auto-serialized to JSON), HTTP.Response, or nothing (204).

api = create_api_router([
    "/api/users" => Dict(
        "GET" => (req, params) -> ["user1", "user2"],
        "POST" => (req, params) -> json_response(Dict("id" => 1); status=201)
    ),
    "/api/users/:id" => Dict(
        "GET" => (req, params) -> Dict("id" => parse(Int, params[:id]))
    ),
    "/api/protected" => Dict(
        "GET" => handler,
        :middleware => [BearerAuthMiddleware(validate)]  # per-route
    )
])

Request Extractors

Utility functions for parsing request data.

json_body(req)           # Parse JSON body -> Dict or nothing
json_body(req, T)        # Parse JSON body into type T
text_body(req)           # Raw body as String or nothing
form_body(req)           # URL-encoded form data -> Dict or nothing
query_params(req)        # Query string -> Dict{String,String}

json_response(data; status, headers)

Create an HTTP.Response with JSON-serialized body and Content-Type: application/json.

json_response(["a", "b"])                           # 200 + JSON
json_response(Dict("error" => "nope"); status=400)  # 400 + JSON

Static Files

Mount a directory of files (CSS, JS, images, fonts, …) under a URL prefix. Each file becomes its own GET route at registration time. Ported 1-1 from Oxygen.jl's staticfiles / dynamicfiles. Mounts feed the SSG too — build(app) copies them under output_dir at the same URL path.

staticfiles(app, folder, mountdir="static"; headers, loadfile)

Walk folder and register every file as a GET route under mountdir. File content is read and the HTTP.Response is cached at registration — serving a request just hands back the precomputed response. headers is applied to every response (use it for Cache-Control etc.). MIME type is inferred from the file extension via MIMEs.jl. An index.html is also aliased at the bare directory path (e.g. /docs/index.html/docs).

app = App(...)

# Mount everything under ./public at /static/*
staticfiles(app, joinpath(@__DIR__, "public"), "static";
            headers = ["Cache-Control" => "public, max-age=3600"])

# /static/app.js, /static/img/logo.png, ... all served from cache.
# build(app) writes the same files under dist/static/.

dynamicfiles(app, folder, mountdir="static"; headers, loadfile)

Same as staticfiles but file contents are RE-READ on every request — edits on disk show up without a server restart. Use during development; prefer staticfiles in production for the cached fast-path.

dynamicfiles(app, "./content", "blog";
             headers = ["Cache-Control" => "no-cache"])

# /blog/post-1.md re-reads from disk every time.

Conflict semantics

Page routes are matched FIRST, static mounts as a fallback — same precedence as Oxygen's HTTP.Router (explicit registrations win). WebSocket upgrades and the Tailwind /styles.css special-case both run BEFORE either table is consulted, so they always win against a colliding static path.

WebSockets

Per-path WebSocket routing with parameterized paths, channel subscriptions, and middleware on upgrade. Ported from Oxygen.jl's WebSocket pattern.

websocket(path, handler; middleware)

Register a WebSocket route. Handler receives a WebSocket object (and optional params dict for parameterized routes). Middleware runs on the HTTP upgrade request.

# Echo server
websocket("/ws/echo") do ws
    for msg in ws
        WebSockets.send(ws, "Echo: " * String(msg))
    end
end

# With path parameters
websocket("/ws/room/:id") do ws, params
    room_id = params[:id]
    for msg in ws
        WebSockets.send(ws, "[$room_id] " * String(msg))
    end
end

# With auth middleware on upgrade
websocket("/ws/admin", handler; middleware=[BearerAuthMiddleware(validate)])

Channels

First-class channel/room subscriptions multiplexed over a single WebSocket connection. Connections subscribe to channels and receive targeted broadcasts.

# Server-side channel API
subscribe(conn, "chat")
unsubscribe(conn, "chat")
broadcast_channel("chat", Dict("type" => "message", "text" => "hello"))
broadcast_channel("chat", msg, exclude_conn)  # exclude sender

# Callbacks
on_channel_message() do channel, conn, msg
    println("[$channel] $(msg)")
end

# Query
channel_connections("chat")  # Vector{WSConnection}
channel_count("chat")        # Int

Connection Lifecycle

Callbacks for WebSocket connection/disconnection events.

on_ws_connect() do conn
    println("Connected: $(conn.id)")
end

on_ws_disconnect() do conn
    println("Disconnected: $(conn.id)")
end

# Broadcast to all connections
broadcast_all(Dict("type" => "announcement", "text" => "hello"))

# Connection info
ws_connection_count()  # Int
ws_connection_ids()    # Vector{String}

Hot Module Replacement

The dev server provides automatic hot module replacement with signal state preservation. File changes are detected instantly via OS-level file watching, only the changed island recompiles, and the browser updates automatically via WebSocket — zero user action required. All components and routes share a single application scope — declare dependencies once in app.jl, then any route or component file can reference them without per-file imports.

How It Works

Save a file in your editor. The browser updates automatically. No manual refresh. Counter stays at 7.

# 1. FileWatching detects change (instant, OS-level, no polling)
# 2. Only the changed island recompiles (~2-3s, not all islands)
# 3. New WASM bytes pushed to browser via WebSocket
# 4. Browser snapshots signal values from old WASM module
# 5. New WASM module instantiates
# 6. Signal values restored (if count + types match)
# 7. Effects re-fire with new code + preserved state

# Start dev server:
julia +1.12 --project=. app.jl dev

What Triggers What

# Component .jl change --> surgical island recompile + WS push
#   Browser: island re-hydrates, signal state preserved
#
# CSS / Tailwind change --> rebuild CSS + WS push
#   Browser: stylesheet replaced, no reload, no state loss
#
# Route .jl change --> reload route + WS push
#   Browser: full page reload (SSR content changed)

Signal State Preservation

Before swapping the WASM module, the browser reads all signal_* globals from the old module. After instantiating the new module, it compares signal count and types. If they match, old values are restored. Same heuristic as React Fast Refresh.

# State PRESERVED (same signal count + types):
#   Change effect logic, counter keeps its value
#
# State RESET (signals changed):
#   Added/removed a signal, or changed type: fresh start