Skip to content

browsing.kt

src/jvmMain/kotlin/afield/app/server/browsing.kt · 1047 lines

package afield.app.server

import afield.app.protocol.Handled
import afield.runtime.lifecycle.RunsAt
import afield.runtime.lifecycle.inMain
import afield.runtime.lifecycle.installHandlers
import afield.app.protocol.rawHandlers
import afield.app.server.thisSession
import afield.data.query.graph.GraphWalkOptions
import afield.data.query.graph.GraphWalker
import afield.data.query.graph.LiveGraphEdge
import afield.data.query.graph.LiveGraphInspectable
import afield.data.query.graph.graphPaths
import io.ktor.websocket.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.lang.ref.WeakReference
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.roundToInt
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.reflect.KClass
import kotlin.reflect.full.superclasses

The object inspector behind /browse: a live, clickable tree of any value, reached from a box with Inspector.linkFor.

A value takes part by implementing Browsable (a heading, a directory of named children, optional HTML) or HasBrowsable. Other values fall back to BrowsableAdapters and to the HTML renderers registered with LeafRenderers. installInspectorHandler installs the socket handler the browser's inspector page talks to.

Public surface

interface Browsable : LiveGraphInspectable

Anything you want to expose in the inspector implements this. A dir is also this object's LiveGraphInspectable edges, so graphPaths(...) reaches the same children the inspector lists.

fun heading(): String = this::class.simpleName ?: "…"

fun dir(): Map<String, Any?>? = null

override fun liveGraphChildren(): Iterable<LiveGraphEdge>

    override fun liveGraphChildren(): Iterable<LiveGraphEdge> =
        dir()?.keys?.map { k -> LiveGraphEdge(k) { dir()?.get(k) } } ?: emptyList()

fun html(): String? = null

fun version(): Long = 0

interface HasBrowsable { fun browsable(): Browsable? }

Wrapper: keep all inspector logic inside this method. If null, treat as null.

object LeafRenderers

Register custom HTML renderers for non-Browsable values (leaf fallback).

object LeafRenderers {
    private val byClass = ConcurrentHashMap<KClass<*>, MutableList<(Any, Browsable?, String?) -> String?>>()

fun <T : Any> register(klass: KClass<T>, renderer: (T, Browsable?, String?) -> String?) {

    fun <T : Any> register(klass: KClass<T>, renderer: (T, Browsable?, String?) -> String?) {
        @Suppress("UNCHECKED_CAST")
        val r = renderer as (Any, Browsable?, String?) -> String?
        byClass.computeIfAbsent(klass) { mutableListOf() }.add(r)
    }

fun render(v: Any, parent: Browsable?, title: String?): String?

    fun render(v: Any, parent: Browsable?, title: String?): String? {
        val rs = byClass[v::class] ?: return null
        for (r in rs) {
            val out = r(v, parent, title)
            if (out != null) return out
        }
        return null
    }

internal fun defaultLeaf(v: Any?): String

    internal fun defaultLeaf(v: Any?): String =
        when (v) {
            null -> "<i>null</i>"
            is String -> (ToStringHighlighter.render(v))
            is Number, is Boolean -> (ToStringHighlighter.render(v.toString()))
            else -> (ToStringHighlighter.render(v.toString()))
        }

    private fun escapeHtml(s: String): String =
        s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}

Inspector core

typealias RootsProvider = () -> Map<String, Any?>

object Inspector

object Inspector {
    private data class RetainedLink(val obj: Any, val createdAtMs: Long)

const val DEFAULT_PAGE = 200

Entries per open/more reply. A directory larger than this is paged; see handle.

    private const val MAX_PAGE = 2000

Configuration

fun setRoots(provider: RootsProvider) { rootsProvider = provider }

fun linkPathFor(obj: Any, browsePath: String = "/browse", uid : String? = null, options: GraphWalkOptions = GraphWalkOptions()): String

Make a deep-link URL (path) for an object. Multiple calls can produce different tokens; tokens are process-local and may expire if the object is GC'ed. If you need absolute URLs, prepend your own origin, e.g. "http://localhost:9092" + linkPathFor(obj).

    fun linkPathFor(obj: Any, browsePath: String = "/browse", uid : String? = null, options: GraphWalkOptions = GraphWalkOptions()): String {
        val token = uid ?: "r${idGen.getAndIncrement()}"
        linkTokens[token] = WeakReference(obj)
        linkOptions[token] = options
        retainLinkToken(token, obj)
        return "$browsePath?root=$token"
    }

fun linkFor(obj: Any, browsePath: String = "/browse", uid : String = Uuid.random().toString(), options: GraphWalkOptions = GraphWalkOptions()): String

Convenience alias naming matches your phrasing.

    @OptIn(ExperimentalUuidApi::class)
    fun linkFor(obj: Any, browsePath: String = "/browse", uid : String = Uuid.random().toString(), options: GraphWalkOptions = GraphWalkOptions()): String =
        linkPathFor(obj, browsePath, uid, options)

fun changed(obj: Any)

Notify watchers that a Browsable changed. Accepts Browsable, HasBrowsable, or raw object.

    fun changed(obj: Any) {
        val target = unwrapToBrowsable(obj) ?: obj
        val ids = items.entries.asSequence()
            .filter { it.value.obj === target || it.value.obj === obj }
            .map { it.key }.toList()
        if (ids.isEmpty()) return
        val version = (unwrapToBrowsable(target) ?: (target as? Browsable))?.version() ?: 0L
        ids.forEach { nodeId ->
            nodeVersions[nodeId] = version
            val msg = json.encodeToString(ChangedPush.serializer(), ChangedPush(nodeId = nodeId, version = version))
            subscribersByNode[nodeId]?.forEach { client -> runCatching { client.send(msg) } }
        }
    }

Internal plumbing

    private var rootsProvider: RootsProvider = { emptyMap() }

internal class Child(val name: String, val segment: String? = name, val value: () -> Any?)

One named child of a directory. value is evaluated when the page is listed, never earlier. segment is the GraphQuery edge name that reaches it from its parent (SAME when the child is reached by the parent's own path, null when no path reaches it, e.g. a primitive array's element or a map key object).

internal const val SAME = "\u0000same"

Child.segment for a child that has its parent's address (the value of a map entry).

internal fun edgeChildren(any: Any, options: GraphWalkOptions): List<Child>

The walk policy for objects that are neither collections nor Browsable. Matches graphPaths().

    internal fun edgeChildren(any: Any, options: GraphWalkOptions): List<Child> =
        GraphWalker.edges(any, options).map { e -> Child(e.name, e.name.takeIf { '/' !in it }) { e.getter() } }

internal sealed interface Resolved

val obj: Any?

val type: String

class Leaf(val html: () -> String, val text: () -> String, override val obj: Any?, override val type: String) : Resolved

        class Leaf(val html: () -> String, val text: () -> String, override val obj: Any?, override val type: String) : Resolved

A directory. size is null when it is not known without iterating (a bare Iterable); page returns limit children from offset, so a million-element list costs one page.

class Dir

NOTE: obj is Any? (not Browsable) so Inspector.changed(rawObj) will match.

        class Dir(
            val heading: String,
            val size: () -> Int?,
            val page: (offset: Int, limit: Int) -> List<Child>,
            override val obj: Any?,
            override val type: String,
        ) : Resolved
    }

    private class Item(
        val resolved: Resolved,
        /** The objects above this node, outermost first; used to flag a child that is one of them. */
        val ancestors: List<Any>,
        /** This node's GraphQuery address from the root, or null when no path reaches it. */
        val path: List<String>?,
        val options: GraphWalkOptions,
        /** Stable child ids by name, so a re-listed directory keeps its ids and the client its open rows. */
        val childIds: ConcurrentHashMap<String, String> = ConcurrentHashMap(),
    ) {
        val obj: Any? get() = resolved.obj
    }

    private val items = ConcurrentHashMap<String, Item>()
    private val idGen = AtomicLong(1)
    private fun newId(): String = "n" + idGen.getAndIncrement()

deep-link token store (weak so links don't pin objects)

    private val linkTokens = ConcurrentHashMap<String, WeakReference<Any>>()
    private val linkOptions = ConcurrentHashMap<String, GraphWalkOptions>()
    private val retainedLinkTokens = LinkedHashMap<String, RetainedLink>()
    private val retainedLinkTokenTtlMs = 10 * 60 * 1000L
    private val retainedLinkTokenMax = 256

    @Synchronized
    private fun retainLinkToken(token: String, obj: Any) {
        val now = System.currentTimeMillis()
        retainedLinkTokens[token] = RetainedLink(obj, now)
        cleanupRetainedLinkTokens(now)
    }

    @Synchronized
    private fun cleanupRetainedLinkTokens(now: Long) {
        val it = retainedLinkTokens.entries.iterator()
        while (it.hasNext()) {
            val (_, entry) = it.next()
            if (now - entry.createdAtMs > retainedLinkTokenTtlMs) {
                it.remove()
            }
        }
        while (retainedLinkTokens.size > retainedLinkTokenMax) {
            val eldestKey = retainedLinkTokens.entries.firstOrNull()?.key ?: break
            retainedLinkTokens.remove(eldestKey)
        }
    }

    private data class Client(val key: Any, val send: (String) -> Unit) {
        override fun equals(other: Any?) = other is Client && other.key === key
        override fun hashCode(): Int = System.identityHashCode(key)
    }

    private val subscribersByNode = ConcurrentHashMap<String, MutableSet<Client>>()
    private fun subscribe(nodeId: String, client: Client) { subscribersByNode.getOrPut(nodeId) { CopyOnWriteArraySet() }.add(client) }
    private fun unsubscribe(nodeId: String, client: Client) {
        val set = subscribersByNode[nodeId] ?: return
        set.remove(client)
        if (set.isEmpty()) { subscribersByNode.remove(nodeId); nodeVersions.remove(nodeId) }
    }

Last version seen per watched node, so the poller can push when version() (or, for plain collections and objects, their contents) changes without an explicit changed().

    private val nodeVersions = ConcurrentHashMap<String, Long>()

    private fun unwrapToBrowsable(any: Any?): Browsable? = when (any) {
        is Browsable   -> any
        is HasBrowsable -> any.browsable()      // may be null
        else           -> null
    }

internal class MapEntryView(val key: Any?, val value: Any?)

A map entry whose key is not a plain value: shown as a two-row directory, key then value.

internal fun resolve(any: Any?, parent: Browsable?, title: String?, options: GraphWalkOptions = GraphWalkOptions()): Resolved

body — 125 lines, from line 254
    internal fun resolve(any: Any?, parent: Browsable?, title: String?, options: GraphWalkOptions = GraphWalkOptions()): Resolved {
        val b0 = unwrapToBrowsable(any)
        val type = InspectorValues.typeName(any)

        return when {
            // 1) The object already is Browsable (or HasBrowsable)
            b0 != null -> browsableResolved(b0, any, parent, title, type, options)

            // 2) Treat HasBrowsable with null like real null
            any == null || any is HasBrowsable -> Resolved.Leaf(html = { "<i>null</i>" }, text = { "null" }, obj = null, type = "null")

            any is MapEntryView -> Resolved.Dir(
                heading = title ?: "entry",
                size = { 2 },
                page = { o, l -> listOf(Child("key", null) { any.key }, Child("value", SAME) { any.value }).drop(o).take(l) },
                obj = any, type = "Map.Entry",
            )

            // 2.5) Maps, collections and arrays: read live and in pages, so neither a mutation nor a
            // million entries surprises the client.
            any is Map<*, *> -> Resolved.Dir(
                heading = title ?: type,
                size = { any.size },
                page = { o, l -> mapPage(any, o, l) },
                obj = any, type = type,
            )

            any is List<*> -> Resolved.Dir(
                heading = title ?: type,
                size = { any.size },
                page = { o, l ->
                    val end = minOf(any.size, o + l)
                    if (o >= end) emptyList() else (o until end).map { i -> Child("[$i]", "$i") { any[i] } }
                },
                obj = any, type = type,
            )

            any is Iterable<*> -> Resolved.Dir(
                heading = title ?: type,
                size = { (any as? Collection<*>)?.size },
                page = { o, l -> any.asSequence().drop(o).take(l).mapIndexed { i, v -> Child("[${o + i}]", if (any is Collection<*>) "${o + i}" else null) { v } }.toList() },
                obj = any, type = type,
            )

            InspectorValues.isArray(any) -> Resolved.Dir(
                heading = title ?: type,
                size = { java.lang.reflect.Array.getLength(any) },
                page = { o, l ->
                    val n = java.lang.reflect.Array.getLength(any)
                    val end = minOf(n, o + l)
                    // Object arrays are GraphQuery edges by index; primitive arrays are leaves to the walker.
                    val seg = any is Array<*>
                    if (o >= end) emptyList() else (o until end).map { i -> Child("[$i]", if (seg) "$i" else null) { java.lang.reflect.Array.get(any, i) } }
                },
                obj = any, type = type,
            )

            // 3) Try adapters: turn arbitrary types into a Browsable wrapper
            else -> {
                val adapted = BrowsableAdapters.adapt(any, parent, title)
                if (adapted != null) return browsableResolved(adapted, any, parent, title, type, options)

                // 4) Plain objects (data classes, user classes): a directory of exactly the edges
                // graphPaths() walks — LiveGraphInspectable children, then Kotlin properties in
                // constructor order. Values (numbers, strings, enums, JDK types) stay leaves.
                val edges = if (InspectorValues.isPlainValue(any)) emptyList() else edgeChildren(any, options)
                if (edges.isNotEmpty()) {
                    Resolved.Dir(
                        heading = title ?: type,
                        size = { edgeChildren(any, options).size },
                        page = { o, l -> edgeChildren(any, options).drop(o).take(l) },
                        obj = any, type = type,
                    )
                } else {
                    Resolved.Leaf(
                        html = { LeafRenderers.render(any, parent, title) ?: LeafRenderers.defaultLeaf(any) },
                        text = { InspectorValues.fullText(any) },
                        obj = any, type = type,
                    )
                }
            }
        }
    }

    private fun browsableResolved(b: Browsable, raw: Any?, parent: Browsable?, title: String?, type: String, options: GraphWalkOptions): Resolved {
        val dirNow = b.dir()
        return if (dirNow != null) {
            // The walker's edges for b: its dir() first (Browsable is LiveGraphInspectable), then
            // any public properties, so every row is a path graphPaths() resolves.
            Resolved.Dir(
                heading = b.heading(),
                size = { edgeChildren(b, options).size },
                page = { o, l -> edgeChildren(b, options).drop(o).take(l) },
                obj = raw,                                 // keep RAW object for change matching
                type = type,
            )
        } else {
            Resolved.Leaf(
                html = { b.html() ?: LeafRenderers.render(b, parent, title) ?: LeafRenderers.defaultLeaf(b) },
                text = { InspectorValues.fullText(raw) },
                obj = raw, type = type,
            )
        }
    }

    private fun mapPage(m: Map<*, *>, offset: Int, limit: Int): List<Child> {
        // Names are key.toString(), made unique: 1 and "1" are different keys with the same text.
        val seen = HashMap<String, Int>()
        val out = ArrayList<Child>()
        var i = 0
        for ((k, v) in m.entries) {
            val base = k?.toString() ?: "null"
            val n = seen.merge(base, 1, Int::plus)!!
            if (i >= offset + limit) break
            if (i >= offset) {
                val name = if (n == 1) base else "$base ($n)"
                // GraphQuery names a map edge key.toString() and skips null keys; a repeated name
                // reaches only the first, so the later ones are not addressable.
                val seg = if (k == null || n > 1 || '/' in base) null else base
                out += if (InspectorValues.isPlainValue(k)) Child(name, seg) { v } else Child(name, seg) { MapEntryView(k, v) }
            }
            i++
        }
        return out
    }

Protocol

    private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }

    @Serializable private data class Request(
        val channel: String? = null,
        val reqId: String,
        val op: String,
        val id: String? = null,
        val watch: Boolean? = null,
        val root: String? = null, // deep-link token
        val offset: Int? = null,  // "more": first entry wanted
        val limit: Int? = null,   // page size, default DEFAULT_PAGE
        val query: String? = null, // "query": a graphPaths() pattern, run from the root of node [id]
    )

entries rows are [name, id, kind, type, summary, flag, path]. Clients written against the original [name, id] pairs read the first two and ignore the rest. kind is dir or leaf, so a client need not open a child to draw its disclosure triangle; flag is "", cycle (the child is one of its own ancestors) or error (reading it threw). path is the row's address from the root as a graphPaths() / graphPath() path, "" when none reaches it.

    @Serializable
    private data class DirResp(
        val reqId: String,
        val ok: Boolean = true,
        val type: String = "dir",
        val parentId: String? = null,
        val id: String? = null,
        val heading: String? = null,
        val entries: List<List<String>>,
        val version: Long? = null,
        val valueType: String? = null,
        val total: Int? = null,
        val offset: Int = 0,
        val more: Boolean = false,
    )

    @Serializable private data class LeafResp(
        val reqId: String,
        val ok: Boolean = true,
        val type: String = "leaf",
        @SerialName("nodeId") val nodeId: String,
        val html: String? = null,
        val value: String? = null,
        val version: Long? = null,
        val valueType: String? = null,
    )

Reply to query: matches rows are [path, kind, type, summary], at most limit of total.

    @Serializable
    private data class QueryResp(
        val reqId: String,
        val ok: Boolean = true,
        val type: String = "query",
        val query: String,
        val matches: List<List<String>>,
        val total: Int,
        val truncated: Boolean = false,
    )

    @Serializable private data class ErrorResp(val reqId: String, val ok: Boolean = false, val error: String)
    @Serializable private data class ChangedPush(val type: String = "changed", val nodeId: String, val version: Long? = null)

    private fun error(reqId: String, message: String) = json.encodeToString(ErrorResp.serializer(), ErrorResp(reqId, error = message))

Never throws: a value that cannot be fingerprinted (an element read throws, a concurrent mutation) is simply not polled, and the request that asked still gets its answer.

    private fun versionOf(obj: Any?): Long? = runCatching {
        (unwrapToBrowsable(obj))?.version() ?: InspectorValues.fingerprint(obj)
    }.getOrNull()

List one page of item's children, minting (or reusing) their ids.

source — 56 lines, from line 454
    private fun listPage(item: Item, dir: Resolved.Dir, offset: Int, limit: Int): Pair<List<List<String>>, Boolean> {
        val ancestors = item.obj?.let { item.ancestors + it } ?: item.ancestors
        val page = dir.page(offset, limit + 1)
        val more = page.size > limit
        val rows = page.take(limit).map { child ->
            val cid = item.childIds.computeIfAbsent(child.name) { newId() }
            val childPath = when (val seg = child.segment) {
                null -> null
                SAME -> item.path
                else -> item.path?.plus(seg)
            }
            val pathText = childPath?.joinToString("/").orEmpty()
            val (value, threw) = try { child.value() to null } catch (t: Throwable) { null to t }
            if (threw != null) {
                val msg = "${threw::class.simpleName}: ${threw.message ?: ""}".trim()
                items[cid] = Item(
                    Resolved.Leaf(html = { "<span class='inspector-error'>${escapeHtml(msg)}</span>" }, text = { threw.stackTraceToString() }, obj = null, type = "error"),
                    ancestors, childPath, item.options,
                )
                return@map listOf(child.name, cid, "leaf", "error", "threw $msg", "error", pathText)
            }
            val resolved = try { resolve(value, dir.obj as? Browsable, child.name, item.options) } catch (t: Throwable) {
                Resolved.Leaf(html = { escapeHtml(t.toString()) }, text = { t.toString() }, obj = value, type = InspectorValues.typeName(value))
            }
            items[cid] = Item(resolved, ancestors, childPath, item.options)
            val cycle = value != null && !InspectorValues.isPlainValue(value) && ancestors.any { it === value }
            val kind = if (resolved is Resolved.Dir) "dir" else "leaf"
            val summary = if (cycle) "↺ " + InspectorValues.summary(value, resolved) else InspectorValues.summary(value, resolved)
            listOf(child.name, cid, kind, resolved.type, summary, if (cycle) "cycle" else "", pathText)
        }
        return rows to more
    }

    private fun reply(reqId: String, id: String, item: Item, offset: Int, limit: Int, parentId: String?): String =
        when (val r = item.resolved) {
            is Resolved.Leaf -> {
                val version = versionOf(r.obj)
                version?.let { nodeVersions[id] = it }
                val html = try { r.html() } catch (t: Throwable) { "<span class='inspector-error'>${escapeHtml(t.toString())}</span>" }
                val text = try { r.text() } catch (t: Throwable) { t.toString() }
                json.encodeToString(LeafResp.serializer(), LeafResp(reqId, nodeId = id, html = html, value = text, version = version, valueType = r.type))
            }
            is Resolved.Dir -> {
                val (entries, more) = listPage(item, r, offset, limit)
                val version = versionOf(r.obj)
                if (offset == 0) version?.let { nodeVersions[id] = it }
                json.encodeToString(
                    DirResp.serializer(),
                    DirResp(
                        reqId = reqId, parentId = parentId, id = id, heading = r.heading, entries = entries,
                        version = version, valueType = r.type, total = runCatching { r.size() }.getOrNull(),
                        offset = offset, more = more,
                    )
                )
            }
        }

fun handle(text: String, clientKey: Any, push: (String) -> Unit): String {

body — 63 lines, from line 511
    fun handle(text: String, clientKey: Any, push: (String) -> Unit): String {
        val client = Client(clientKey) { payload -> push(payload) }
        return try {
            val req = json.decodeFromString(Request.serializer(), text)
            if (req.channel != null && req.channel != "__browsing__") return ""
            val limit = (req.limit ?: DEFAULT_PAGE).coerceIn(1, MAX_PAGE)
            when (req.op) {
                "getRoots" -> {
                    // If a deep-link token is provided, start from THAT object (as the unseen parent).
                    val token = req.root
                    if (token != null) {
                        val objAny = linkTokens[token]?.get()
                            ?: return error(req.reqId, "This inspector link has expired (the value it showed is gone). Run the box again.")
                        val rootId = newId()
                        val options = linkOptions[token] ?: GraphWalkOptions()
                        val item = Item(resolve(objAny, null, null, options), emptyList(), emptyList(), options)
                        items[rootId] = item
                        return reply(req.reqId, rootId, item, 0, limit, parentId = null)
                    }

                    // Default: enumerate global roots
                    val roots = rootsProvider()
                    val rootId = newId()
                    val item = Item(Resolved.Dir("Root", { roots.size }, { o, l -> roots.entries.drop(o).take(l).map { (k, v) -> Child(k) { v } } }, roots, "Roots"), emptyList(), emptyList(), GraphWalkOptions())
                    items[rootId] = item
                    reply(req.reqId, rootId, item, 0, limit, parentId = null)
                }

                "open" -> {
                    val id = req.id ?: return error(req.reqId, "Missing id")
                    val item = items[id] ?: return error(req.reqId, "Unknown id: $id")
                    if (req.watch == true) subscribe(id, client)
                    reply(req.reqId, id, item, 0, limit, parentId = id)
                }

                "more" -> {
                    val id = req.id ?: return error(req.reqId, "Missing id")
                    val item = items[id] ?: return error(req.reqId, "Unknown id: $id")
                    if (item.resolved !is Resolved.Dir) return error(req.reqId, "Not a directory: $id")
                    reply(req.reqId, id, item, (req.offset ?: 0).coerceAtLeast(0), limit, parentId = id)
                }

                "query" -> {
                    val id = req.id ?: return error(req.reqId, "Missing id")
                    val item = items[id] ?: return error(req.reqId, "Unknown id: $id")
                    val pattern = req.query?.trim().orEmpty()
                    if (pattern.isEmpty()) return error(req.reqId, "Empty query")
                    query(req.reqId, item, pattern, (req.limit ?: QUERY_LIMIT).coerceIn(1, MAX_PAGE))
                }

                "unsubscribe" -> {
                    val id = req.id ?: return error(req.reqId, "Missing id")
                    unsubscribe(id, client)
                    json.encodeToString(DirResp.serializer(), DirResp(reqId = req.reqId, ok = true, entries = emptyList()))
                }

                else -> error(req.reqId, "Unknown op: ${req.op}")
            }
        } catch (t: Throwable) {
            val reqId = runCatching { json.decodeFromString(Request.serializer(), text).reqId }.getOrElse { "?" }
            json.encodeToString(ErrorResp.serializer(), ErrorResp(reqId, ok = false, error = (t.message ?: t.toString())))
        }
    }

fun handle(text: String): String = handle(text, clientKey = Any()) { /* drop pushes */ }

const val QUERY_LIMIT = 500

Matches listed per query reply.

The walk behind one query stops after this many slots, so ** over a huge graph answers.

    private const val QUERY_MAX_REFS = 20_000

Run pattern with graphPaths from the root item belongs to — the node's own path is prefixed, so the query reads the same as it would in box code against the value given to tree(...). Only an item with a path (every row reached by edges) can be queried.

    private fun query(reqId: String, item: Item, pattern: String, limit: Int): String {
        val base = item.path ?: return error(reqId, "This row has no path from the root, so it cannot be queried")
        val target = item.obj
        val options = item.options.copy(maxRefs = minOf(item.options.maxRefs, QUERY_MAX_REFS))
        val found = target.graphPaths(pattern, options)
        val rows = found.asSequence().take(limit).map { ref ->
            val v = runCatching { ref.get() }.getOrNull()
            val r = runCatching { resolve(v, null, ref.name, item.options) }.getOrNull()
            listOf(
                (base + ref.rawPath.split('/')).joinToString("/"),
                if (r is Resolved.Dir) "dir" else "leaf",
                InspectorValues.typeName(v),
                InspectorValues.summary(v, r),
            )
        }.toList()
        return json.encodeToString(
            QueryResp.serializer(),
            QueryResp(reqId, query = pattern, matches = rows, total = found.size, truncated = found.size >= options.maxRefs),
        )
    }

internal fun nodeCount(): Int = items.size

Number of live node records; for tests that check re-listing reuses ids.

Periodic version polling for watched nodes (best-effort, avoids UI focus issues).

    private val versionPoller = java.util.Timer("inspector-version-poll", true).apply {
        schedule(object : TimerTask() {
            override fun run() {
                try {
                    pollVersions()
                } catch (_: Throwable) { /* ignore */ }
            }
        }, 1000L, 1000L)
    }

internal fun pollVersions()

Push changed for every watched node whose version moved: Browsable.version for browsables, a content fingerprint (InspectorValues.fingerprint) for plain collections, arrays and objects, so tree(list) follows list.add(...) without anyone calling changed.

    internal fun pollVersions() {
        // Snapshot subscriber node ids to avoid holding locks while querying versions.
        val nodes = subscribersByNode.keys.toList()
        for (id in nodes) {
            val item = items[id] ?: continue
            val obj = item.obj ?: continue
            val v = runCatching { versionOf(obj) }.getOrNull() ?: continue
            val last = nodeVersions[id]
            if (last == null || last != v) {
                nodeVersions[id] = v
                if (last == null) continue
                val msg = json.encodeToString(ChangedPush.serializer(), ChangedPush(nodeId = id, version = v))
                subscribersByNode[id]?.forEach { client -> runCatching { client.send(msg) } }
            }
        }
    }
}

internal object InspectorValues

How the inspector names, summarises and takes apart values that are not Browsable. Kept apart from Inspector so the rules are testable one value at a time.

internal object InspectorValues {
    private const val SUMMARY_CHARS = 120
    private const val TEXT_CHARS = 200_000
    private const val FINGERPRINT_ELEMENTS = 2000

fun isPlainValue(v: Any?): Boolean

Strings, numbers, booleans, chars, enums: shown as themselves, never taken apart.

    fun isPlainValue(v: Any?): Boolean =
        v == null || v is CharSequence || v is Number || v is Boolean || v is Char || v is Enum<*> || v is KClass<*> || v is Class<*>

fun isArray(v: Any?): Boolean = v != null && v.javaClass.isArray

fun typeName(v: Any?): String

    fun typeName(v: Any?): String {
        if (v == null) return "null"
        val c = v.javaClass
        if (c.isArray) {
            val ct = c.componentType
            return when (ct) {
                Int::class.javaPrimitiveType -> "IntArray"
                Double::class.javaPrimitiveType -> "DoubleArray"
                Float::class.javaPrimitiveType -> "FloatArray"
                Long::class.javaPrimitiveType -> "LongArray"
                Byte::class.javaPrimitiveType -> "ByteArray"
                Short::class.javaPrimitiveType -> "ShortArray"
                Char::class.javaPrimitiveType -> "CharArray"
                Boolean::class.javaPrimitiveType -> "BooleanArray"
                else -> "Array<${ct.simpleName}>"
            }
        }
        return when (v) {
            is String -> "String"
            is Int -> "Int"
            is Double -> "Double"
            is Float -> "Float"
            is Long -> "Long"
            is Boolean -> "Boolean"
            else -> c.simpleName.ifEmpty { c.name.substringAfterLast('.') }
        }
    }

    private fun clip(s: String, n: Int = SUMMARY_CHARS): String =
        if (s.length <= n) s else s.take(n - 1) + "…"

fun summary(v: Any?, resolved: Inspector.Resolved? = null): String

The one-line preview drawn beside a name.

    fun summary(v: Any?, resolved: Inspector.Resolved? = null): String = try {
        when {
            v == null -> "null"
            v is CharSequence -> {
                val s = v.toString()
                val oneLine = s.replace("\n", "⏎")
                "\"" + clip(oneLine) + "\"" + if (s.length > SUMMARY_CHARS) " (${s.length} chars)" else ""
            }
            v is Enum<*> -> v.name
            isPlainValue(v) -> v.toString()
            v is Inspector.MapEntryView -> clip("${v.key} → ${summary(v.value)}")
            v is Map<*, *> -> "${v.size} " + if (v.size == 1) "entry" else "entries"
            v is Collection<*> -> "${v.size} " + if (v.size == 1) "item" else "items"
            isArray(v) -> java.lang.reflect.Array.getLength(v).let { "$it " + if (it == 1) "item" else "items" }
            v is Iterable<*> -> "iterable"
            resolved is Inspector.Resolved.Dir && !overridesToString(v) -> {
                val names = runCatching { resolved.page(0, 4).map { it.name } }.getOrDefault(emptyList())
                val n = runCatching { resolved.size() }.getOrNull() ?: names.size
                "{" + names.joinToString(", ") + if (n > names.size) ", …}" else "}"
            }
            else -> clip(v.toString().replace("\n", "⏎"))
        }
    } catch (t: Throwable) {
        "toString threw ${t::class.simpleName}"
    }

fun fullText(v: Any?): String

The full text a "copy" button puts on the clipboard.

    fun fullText(v: Any?): String = try {
        clip(v?.toString() ?: "null", TEXT_CHARS)
    } catch (t: Throwable) {
        "toString threw $t"
    }

    private fun overridesToString(v: Any): Boolean =
        runCatching { v.javaClass.getMethod("toString").declaringClass != Any::class.java }.getOrDefault(true)

    private fun elementHash(v: Any?): Int = when {
        v == null -> 0
        isPlainValue(v) -> v.hashCode()
        else -> System.identityHashCode(v)
    }

fun fingerprint(v: Any?): Long?

A cheap content hash for change polling: size plus the first FINGERPRINT_ELEMENTS elements (value hash for plain values, identity for objects). Null for values that are not polled.

    fun fingerprint(v: Any?): Long? = when {
        v == null || isPlainValue(v) -> null
        v is Inspector.MapEntryView -> null
        v is Map<*, *> -> {
            var h = v.size.toLong()
            var n = 0
            for ((k, x) in v.entries) { if (n++ >= FINGERPRINT_ELEMENTS) break; h = h * 31 + elementHash(k) * 17 + elementHash(x) }
            h
        }
        v is Collection<*> -> {
            var h = v.size.toLong()
            var n = 0
            for (x in v) { if (n++ >= FINGERPRINT_ELEMENTS) break; h = h * 31 + elementHash(x) }
            h
        }
        isArray(v) -> {
            val len = java.lang.reflect.Array.getLength(v)
            var h = len.toLong()
            for (i in 0 until minOf(len, FINGERPRINT_ELEMENTS)) h = h * 31 + elementHash(java.lang.reflect.Array.get(v, i))
            h
        }
        else -> Inspector.edgeChildren(v, GraphWalkOptions()).takeIf { it.isNotEmpty() }
            ?.fold(17L) { h, c -> h * 31 + elementHash(runCatching { c.value() }.getOrNull()) }
    }
}

Handler wiring

fun registerAllBrowsingAdapters()

Every BrowsableAdapters registration the server makes, in one place. A register*BrowsingAdapters() function defined anywhere in src/jvmMain must be called from here, or column()/tree() never see its types (the Assimp/glTF adapters sat unregistered from their introduction until 2026-10-28); InspectorTest scans the source and fails on one that is not. Idempotent.

fun registerAllBrowsingAdapters() {
    if (!allBrowsingAdaptersRegistered.compareAndSet(false, true)) return
    registerExampleRoots()
    registerExampleAdapters()
    registerRendererBrowsingAdapters()
    registerAssimpBrowsingAdapters()
}

private val allBrowsingAdaptersRegistered = java.util.concurrent.atomic.AtomicBoolean(false)

fun installInspectorHandler()

@RunsAt(installHandlers)
fun installInspectorHandler() {
    registerAllBrowsingAdapters()
    rawHandlers.getOrPut("__browsing__") { mutableSetOf() }.add { text ->
        val s = thisSession.get()
        inMain {
            val out = Inspector.handle(text, clientKey = s) { pushJson -> inMain { s.send(pushJson) } }
            if (out.isNotEmpty()) s.send(out)
        }
        Handled.yes
    }
}

Example/demo nodes

class Clock : Browsable

class Clock : Browsable {
    private val fmt = DateTimeFormatter.ofPattern("HH:mm:ss")
    private val ver = AtomicLong(0)
    private val timer = Timer("clock", true).apply {
        scheduleAtFixedRate(object : TimerTask() {
            override fun run() { ver.incrementAndGet(); Inspector.changed(this@Clock) }
        }, 0L, 1000L)
    }

override fun heading(): String = "Clock"

override fun html(): String

    override fun html(): String = "<div style='font-size:24px'><b>${LocalTime.now().format(fmt)}</b></div>"

override fun version(): Long = ver.get()

class Heap : Browsable

class Heap : Browsable {
    private val ver = AtomicLong(0)
    private val rt = Runtime.getRuntime()
    private val timer = Timer("heap", true).apply {
        scheduleAtFixedRate(object : TimerTask() {
            override fun run() { ver.incrementAndGet(); Inspector.changed(this@Heap) }
        }, 0L, 2000L)
    }

override fun heading(): String = "Heap"

override fun html(): String

    override fun html(): String {
        val used = (rt.totalMemory() - rt.freeMemory()).toDouble() / (1024 * 1024)
        val total = rt.totalMemory().toDouble() / (1024 * 1024)
        val pct = if (total > 0) (used / total * 100).roundToInt() else 0
        return """
            <div style="font-family:inherit">
              <div>Used: <b>${"%.1f".format(used)} MiB</b> / ${"%.1f".format(total)} MiB</div>
              <div style="margin-top:6px;height:6px;background:#111;border:1px solid #222;border-radius:3px;overflow:hidden;">
                <div style="height:100%;width:${pct}%;background:#fff;opacity:.35;"></div>
              </div>
            </div>
        """.trimIndent()
    }

override fun version(): Long = ver.get()

class Numbers(private val n: Int) : Browsable

override fun heading(): String = "Numbers"

override fun dir(): Map<String, Any?> = mapOf("$n" to Numbers(n + 1))

class ThreadList : Browsable

override fun heading(): String = "Threads"

override fun html(): String

    override fun html(): String = ToStringHighlighter.render(Thread.getAllStackTraces().keys.joinToString("\n") { it.name })
}

fun registerExampleRoots()

@Suppress("unused")
fun registerExampleRoots() {

    LeafRenderers.register(String::class) { s, _, _ ->
        ToStringHighlighter.render(s) // last resort: always handle
    }

    LeafRenderers.register(String::class) { s, _, title ->
        if (title?.startsWith("code:") == true)
            "<pre style='white-space:pre-wrap;margin:0'>${escapeHtml(s)}</pre>"
        else null
    }
    LeafRenderers.register(String::class) { s, _, _ -> "<div>${escapeHtml(s)}</div>" }

    val clock = Clock()
    val heap  = Heap()

    val url = Inspector.linkFor(clock)        // "/browse?root=r123"
    println(" url for clock is ${url}")

    Inspector.setRoots {
        mapOf(
            "Clock" to clock,
            "Heap" to heap,
            "Numbers" to Numbers(0),
            "Threads" to ThreadList(),
            "Example" to object : Browsable {
                override fun heading(): String = "Example"
                override fun dir(): Map<String, Any?> = mapOf(
                    "String" to "Hello, world! + ${Math.random()}",
                    "List" to listOf(1, 2, 3),
                    "Map" to mapOf(1 to "one", 2 to "two"),
                )
            }
        )
    }
}

typealias AdapterFn = (Any, Browsable?, String?) -> Browsable?

object BrowsableAdapters

object BrowsableAdapters {

Keep registration order per type

    private val byClass = ConcurrentHashMap<KClass<*>, CopyOnWriteArrayList<AdapterFn>>()

Cache of "runtime KClass -> flattened adapters considering full hierarchy"

    private val cache = ConcurrentHashMap<KClass<*>, List<AdapterFn>>()

fun <T : Any> register(klass: KClass<T>, adapter: (T, Browsable?, String?) -> Browsable?) {

    fun <T : Any> register(klass: KClass<T>, adapter: (T, Browsable?, String?) -> Browsable?) {
        @Suppress("UNCHECKED_CAST")
        val fn = adapter as AdapterFn
        byClass.computeIfAbsent(klass) { CopyOnWriteArrayList() }.add(fn)
        cache.clear() // new registration may affect many subclasses; keep it simple & safe
    }

fun adapt(v: Any, parent: Browsable?, title: String?): Browsable?

    fun adapt(v: Any, parent: Browsable?, title: String?): Browsable? {
        // Compute once per runtime class, then reuse
        val chain = cache.computeIfAbsent(v::class) { resolveChain(it) }
        for (fn in chain) {
            val out = fn(v, parent, title)
            if (out != null) return out
        }
        return null
    }

    private fun resolveChain(k: KClass<*>): List<AdapterFn> {
        // Collect KClasses in "most-specific-first" order, without duplicates.
        val ordered = LinkedHashSet<KClass<*>>()

        fun addK(kc: KClass<*>) {
            if (!ordered.add(kc)) return

            // 1) Prefer immediate interfaces (Java view), then recurse (more specific than superclass)
            kc.java.interfaces.forEach { addK(it.kotlin) }

            // 2) Kotlin supertypes (captures mapped types like kotlin.collections.Map)
            kc.supertypes.forEach { (it.classifier as? KClass<*>)?.let(::addK) }

            // 3) Then the superclass (Java view)
            kc.java.superclass?.let { addK(it.kotlin) }
        }

        addK(k)
        ordered.add(Any::class) // allow very generic catch-alls if you want

        // Flatten adapters in that order, preserving registration order within each type
        val out = ArrayList<AdapterFn>()
        for (t in ordered) {
            byClass[t]?.let { out.addAll(it) }
        }
        return out
    }
}

fun registerExampleAdapters()

@Suppress("UNCHECKED_CAST")
fun registerExampleAdapters() {
    BrowsableAdapters.register(Map::class) { m: Map<*, *>, parent, title ->
        if (!m.keys.all { it is String }) return@register null

        val entries = m.entries.associate { (k, v) -> (k as String) to v }
        object : Browsable {
            override fun heading(): String = title ?: "Map"
            override fun dir(): Map<String, Any?> = entries
        }
    }
}

object ToStringHighlighter

Drop-in general-purpose .toString() highlighter for Strings.

object ToStringHighlighter {

Very forgiving number pattern: 123, -1.23, 1_000.5, 1e9, -3.2E-4

    private val numberRegex = Regex("""^[+-]?\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?$""")

Punctuation we want to show as separate tokens (we deliberately allow '.', '_' and '-' to stay inside words)

    private val punctDelims: Set<Char> = setOf(
        ',', ';', ':', '!', '?',
        '(', ')', '[', ']', '{', '}',
        '<', '>', '/', '\\', '|',
        '@', '#', '$', '%', '^', '&', '*',
        '~', '`', '"', '\''
    )

fun render(src: String): String

body — 69 lines, from line 970
    fun render(src: String): String {
        val sb = StringBuilder((src.length * 1.25).toInt())
        sb.append("<div class='tostr' style='white-space:pre-wrap;margin:0;font-family:inherit'>")

        var i = 0
        fun isWs(c: Char) = c.isWhitespace()
        fun isPunct(c: Char) = c in punctDelims
        fun isWordLetter(c: Char) = Character.isLetter(c.code)
        fun isDigit(c: Char) = Character.isDigit(c.code)

        while (i < src.length) {
            val c = src[i]

            // 1) Whitespace: keep as-is (pre-wrap preserves it)
            if (isWs(c)) {
                sb.append(c)
                i++
                continue
            }

            // 2) Punctuation run
            if (isPunct(c)) {
                var j = i + 1
                while (j < src.length && isPunct(src[j])) j++
                val token = src.substring(i, j)
                sb.append("<span class='tok punc'>").append(escapeHtml(token)).append("</span>")
                i = j
                continue
            }

            // 3) Non-whitespace, non-punctuation run (could be word/number/symword)
            var j = i
            while (j < src.length && !isWs(src[j]) && !isPunct(src[j])) j++
            val part = src.substring(i, j)

            val type = classify(part)
            val cls = when (type) {
                Tok.NUM    -> "num"
                Tok.WORD   -> "word"
                Tok.SYMWRD -> "sym"
            }
            sb.append("<span class='tok ").append(cls).append("'>").append(escapeHtml(part)).append("</span>")
            i = j
        }

        sb.append("</div>")
        return sb.toString()
    }

    private enum class Tok { NUM, WORD, SYMWRD }

    private fun classify(part: String): Tok {
        if (part.isEmpty()) return Tok.SYMWRD

        // Prefer number if it matches the relaxed pattern
        if (numberRegex.matches(part)) return Tok.NUM

        // WORD = letters-only (unicode aware)
        var allLetters = true
        for (ch in part) {
            if (!Character.isLetter(ch.code)) { allLetters = false; break }
        }
        if (allLetters) return Tok.WORD

        // Otherwise treat as a word-with-symbols
        return Tok.SYMWRD
    }

}

Utils

private fun escapeHtml(s: String): String =
    s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")