portable
js/ui/portable.ts
fino:ui/portable — host-neutral trees as transferable data.
A fino:ui tree is already plain-ish data, but nothing stops a component
from putting a class instance, a closure, or a cycle in its props. This
module defines the subset that survives leaving the isolate that built it,
and rejects everything else at the boundary rather than at the far end.
That subset is what makes a component's location a deployment choice. The
same tree crosses a realm port via structured clone, an SSE stream as JSON,
or a postMessage to a browser, and the receiver routes type to whatever
implementation it has for that name. Nothing in a portable tree names a
server object, so nothing about where it was rendered leaks into it.
import { h, renderStatic } from 'fino:ui';
import { portableSink } from 'fino:ui/portable';
const tree = renderStatic(() => h('main', { id: 'root' }, 'Ready'), portableSink());Types
type PortableValue =
| null
| boolean
| number
| string
| PortableValue[]
| { [key: string]: PortableValue }
JSON value allowed to cross a UI boundary.
Functions, class instances, non-finite numbers, and cyclic values are rejected before a tree is published.
Interfaces
interface PortableVNode {
Host-neutral component node in transferable form.
Receivers route type to their own named implementation. key is semantic
instance identity for reconciliation; it is not a component implementation
id.
Properties
type: string
Named component implementation requested from the receiver.
props: Record<string, PortableValue>
JSON props interpreted by that implementation.
children: Array<PortableVNode | string>
Ordered child components and text.
key: string | number | null
Stable instance identity, or null when the node is unkeyed.
Classes
class PortableValueError extends TypeError {
Value rejected while converting a tree to portable form.
The message names the property path so a component author can find the prop that cannot cross, rather than learning only that "something" failed.
Readonly Properties
readonly path: string
Dotted path from the tree root to the offending value.
Constructors
constructor(path: string, message: string)
Functions
function toPortable(tree: VNode): PortableVNode
Convert a rendered tree to its transferable form.
The result is structured-clone safe and JSON.stringify safe, and matches
what a JSON round-trip would produce: an undefined prop is omitted, since a
prop set to undefined and one never set are the same absent prop, and an
undefined array element becomes null so positions are preserved.
A value that cannot cross at all — a function, a class instance, a cycle —
throws PortableValueError naming its path rather than being dropped, because
a receiver cannot tell a dropped prop from one that was never sent.
import { h } from 'fino:ui';
import { toPortable } from 'fino:ui/portable';
const tree = toPortable(h('button', { disabled: true }, 'Save'));function portableSink(): Sink<PortableVNode>
Sink that converts each committed tree to its transferable form.
Use it wherever a rendered tree leaves the isolate that produced it: a realm publishing revisions to its parent, a server streaming semantic updates, or a build step recording a tree for a later host to interpret.
import { createRoot } from 'fino:ui';
import { portableSink } from 'fino:ui/portable';
const root = createRoot(App, portableSink());
root.subscribe((tree) => port.postMessage(tree));