npm package: @myexamsai/revelio
Description: Revelio — Server-Driven UI library for React and React Native. Your server defines the UI as JSON, Revelio renders it live with surgical per-node re-renders, Zod validation, real-time WebSocket updates, and cross-node subscriptions.
Latest version: 2.0.0

# Revelio ✨

> *"Revelio!"* — the revealing charm. Your server casts the spell, your app reveals the UI.

**Revelio** is a Server-Driven UI (SDUI) engine for React and React Native. Your server sends a JSON document describing the layout. Revelio renders it live — with surgical per-node re-renders, Zod schema validation, real-time WebSocket updates, and cross-node subscriptions.

```bash
npm install @myexamsai/revelio zod
```

---

## How it works

```
Server                          Client (web or native)
  │                                       │
  │── SduiLayoutDocument (JSON) ──>        │
  │                               SduiLayoutStore
  │                               (normalizes to flat map)
  │                                       │
  │                             SduiLayoutRenderer
  │                             (calls your ComponentFactory)
  │                                       │
  │                             <YourComponent />
  │                             (subscribes to its own node only)
  │                                       │
  │<── action dispatch ───────────────────│
```

Revelio is a **pure rendering engine** — it has zero opinion about what your components look like. You bring your own React components. The server controls which components appear, in what order, and with what state. The client renders exactly what the server describes.

When the server updates a single node, **only that component re-renders**. Nothing else is touched.

---

## Installation

```bash
npm install @myexamsai/revelio zod
```

Same package, same command for web and React Native. Your bundler resolves the correct entry point automatically:

- **Webpack / Vite / Next.js** → imports from `@myexamsai/revelio/web`
- **Metro (React Native / Expo)** → imports from `@myexamsai/revelio/native`

You can also import directly from the right entry point yourself — which is the recommended approach for clarity:

```ts
// In a web project
import { useRevelioDocument } from '@myexamsai/revelio/web'

// In a React Native project
import { useRevelioDocument } from '@myexamsai/revelio/native'

// Platform-agnostic (hooks, store, types) — works in both
import { useSduiNodeSubscription } from '@myexamsai/revelio'
```

---

## Quick start — web

### Step 1: Write your components

Each component receives only an `id` from Revelio and reads its own state from the store:

```tsx
// components/Card.tsx
import { useSduiNodeSubscription } from '@myexamsai/revelio'
import { z } from 'zod'

const cardSchema = z.object({
  title: z.string(),
  subtitle: z.string().optional(),
})

export function Card({ id }: { id: string }) {
  const { state } = useSduiNodeSubscription({ nodeId: id, schema: cardSchema })
  // state.title → string  (fully typed from the schema)
  // state.subtitle → string | undefined

  return (
    <div className="card">
      <h2>{state.title}</h2>
      {state.subtitle && <p>{state.subtitle}</p>}
    </div>
  )
}
```

### Step 2: Register components and render

```tsx
// pages/dashboard.tsx
import { useRevelioDocument } from '@myexamsai/revelio/web'
import { Card } from '../components/Card'
import { Button } from '../components/Button'

export default function DashboardPage() {
  const { renderer, status } = useRevelioDocument({
    url: '/api/sdui/dashboard',
    components: {
      Card:   (id) => <Card id={id} />,
      Button: (id) => <Button id={id} />,
    },
  })

  if (status === 'loading') return <p>Loading…</p>
  return <>{renderer}</>
}
```

### Step 3: Serve the document

```ts
// api/sdui/dashboard.ts
export async function GET() {
  return Response.json({
    version: '1.0.0',
    root: {
      id: 'root',
      type: 'Card',
      state: {
        title: 'Welcome back',
        subtitle: 'Here is your summary',
      },
      children: [
        {
          id: 'cta',
          type: 'Button',
          state: { label: 'View orders', variant: 'primary' },
        },
      ],
    },
  })
}
```

Change the JSON on the server — the UI updates immediately. No frontend deploy.

---

## Quick start — React Native

The API is **identical**. Only the import path differs:

```tsx
// screens/DashboardScreen.tsx
import { useRevelioDocument } from '@myexamsai/revelio/native'
import { View, ActivityIndicator } from 'react-native'
import { Card } from '../components/Card'
import { Button } from '../components/Button'

export default function DashboardScreen() {
  const { renderer, status } = useRevelioDocument({
    url: 'https://api.myapp.com/sdui/dashboard',
    components: {
      Card:   (id) => <Card id={id} />,
      Button: (id) => <Button id={id} />,
    },
  })

  if (status === 'loading') return <ActivityIndicator />
  return <View style={{ flex: 1 }}>{renderer}</View>
}
```

Your `Card` on native uses `View` and `Text`. Revelio never sees them — it only calls your factory with the node ID. The rendering is 100% yours.

---

## Core concepts

### The SduiLayoutDocument

The wire format your server produces:

```ts
interface SduiLayoutDocument {
  version: string                        // e.g. "1.0.0"
  metadata?: {
    id?: string
    name?: string
    description?: string
    createdAt?: string
    updatedAt?: string
    author?: string
  }
  root: SduiLayoutNode
  variables?: Record<string, unknown>    // global variables accessible to all nodes
}

interface SduiLayoutNode {
  id: string                             // unique within the document
  type: string                           // maps to your ComponentFactory
  state?: Record<string, unknown>        // data your component reads
  attributes?: Record<string, unknown>   // styling hints (optional)
  children?: SduiLayoutNode[]            // nested nodes (arbitrary depth)
  reference?: string | string[]          // subscribe to other nodes' state
}
```

**Rules:**
- `id` must be unique across the entire document
- `type` must match a key in your `components` map (or `componentOverrides`)
- `children` can be nested arbitrarily deep — Revelio handles all recursion
- `state` is whatever shape your component expects — validate it with Zod

### ComponentFactory

The bridge between Revelio and your components. A function that receives a node ID and returns a React element:

```ts
type ComponentFactory = (id: string, parentPath?: string[]) => ReactNode
```

The factory receives only `id`. Your component reads its own state via `useSduiNodeSubscription`. This separation is intentional — the factory is a one-line bridge, not a prop-passing layer.

```tsx
// Factory — one line
const CardFactory: ComponentFactory = (id) => <Card id={id} />

// Component — reads its own state
function Card({ id }: { id: string }) {
  const { state } = useSduiNodeSubscription({ nodeId: id, schema: cardSchema })
  return <div>{state.title}</div>
}
```

### Why per-node subscriptions matter

Revelio normalizes the nested document into a flat map. Each component subscribes to exactly one node. Updates are surgical:

```
store.updateNodeState("counter-1", { count: 5 })

  ✓ counter-1 re-renders
  ✗ counter-2 does NOT re-render
  ✗ root does NOT re-render
  ✗ any sibling or parent does NOT re-render
```

This uses React 18's `useSyncExternalStore` with `lastModified` ISO timestamps as snapshots. Tearing-safe in concurrent rendering.

---

## `useRevelioDocument`

The primary hook. Handles fetching, store lifecycle, real-time updates, and action dispatch.

```tsx
import { useRevelioDocument } from '@myexamsai/revelio/web'
// or '@myexamsai/revelio/native'

const { renderer, status, error, store, dispatch } = useRevelioDocument({

  // ── Required ──────────────────────────────────────────────────────────────
  url: '/api/sdui/dashboard',

  // ── Components ────────────────────────────────────────────────────────────
  components: {
    Card:    (id) => <Card id={id} />,
    Button:  (id) => <MyButton id={id} />,
    Counter: (id) => <Counter id={id} />,
  },

  // Component overrides — higher priority than components
  componentOverrides: {
    byNodeId:   { 'featured-card': (id) => <FeaturedCard id={id} /> },
    byNodeType: { Button: (id) => <PrimaryButton id={id} /> },
  },

  // ── Real-time ─────────────────────────────────────────────────────────────
  realtime: true,
  wsUrl: 'wss://api.myapp.com/ws/dashboard', // optional, defaults to url with http→ws

  // ── Actions ───────────────────────────────────────────────────────────────
  onAction: async (action) => {
    const res = await fetch('/api/sdui/action', {
      method: 'POST',
      body: JSON.stringify(action),
    })
    const data = await res.json()
    if (data.document) return { document: data.document }  // full replace
    if (data.nodeId)   return { nodeId: data.nodeId, state: data.state }  // partial update
    return null  // no store update
  },

  // ── Network ───────────────────────────────────────────────────────────────
  fetchHeaders: { Authorization: `Bearer ${token}` },

  // ── UI states ─────────────────────────────────────────────────────────────
  loadingFallback: <Spinner />,
  errorFallback:   (err) => <ErrorBanner message={err.message} />,
  onError:         (err) => console.error('[Revelio]', err),
})
```

### Return values

| Field | Type | Description |
|---|---|---|
| `renderer` | `ReactNode` | Drop directly into JSX — complete UI |
| `status` | `'idle' \| 'loading' \| 'success' \| 'error'` | Current lifecycle state |
| `error` | `Error \| null` | Populated when `status === 'error'` |
| `store` | `SduiLayoutStore` | Underlying store for imperative operations |
| `dispatch` | `(action) => Promise<void>` | Trigger an action manually |

---

## Hooks

### `useSduiNodeSubscription`

Subscribe to a node's state. Re-renders only when that node changes.

```ts
const {
  state,        // typed as z.infer<TSchema> if schema provided, else Record<string, unknown>
  childrenIds,  // string[] — IDs of child nodes
  type,         // string — the node type
  attributes,   // Record<string, unknown> | undefined
  reference,    // string | string[] | undefined
  exists,       // boolean — false if node has been removed
  node,         // raw SduiLayoutNode
} = useSduiNodeSubscription({
  nodeId: 'my-node',
  schema: myZodSchema,   // optional — validates state and infers types
})
```

**With Zod schema — fully typed state:**

```tsx
const schema = z.object({
  count: z.number(),
  label: z.string(),
  color: z.string().optional(),
})

function Counter({ id }: { id: string }) {
  const { state } = useSduiNodeSubscription({ nodeId: id, schema })
  // state.count  → number ✓
  // state.label  → string ✓
  // state.color  → string | undefined ✓
}
```

### `useSduiLayoutAction`

Returns the store instance for imperative updates:

```ts
const store = useSduiLayoutAction()

// Update a node's state (merges with existing)
store.updateNodeState('counter-1', { count: 5 })

// Replace the entire layout
store.updateLayout(newDocument)

// Incremental patch — only listed nodes are updated
store.mergeLayout(patchDocument)

// Patch with explicit removals
store.mergeLayout(patchDocument, ['node-to-remove-1', 'node-to-remove-2'])

// Restore to the document state when first loaded
store.resetToInitial()

// Export current state as a SduiLayoutDocument
const doc = store.getDocument()

// Global variables
store.updateVariable('userId', '123')
store.deleteVariable('userId')
store.updateVariables({ theme: 'dark', locale: 'en' })
```

### `useRenderNode`

Creates rendering functions for container components. Use this when a component renders its children:

```tsx
import { useSduiNodeSubscription, useRenderNode } from '@myexamsai/revelio'

function Container({ id, parentPath = [] }: { id: string; parentPath?: string[] }) {
  const { childrenIds } = useSduiNodeSubscription({ nodeId: id })
  const { renderChildren, renderNode, currentPath } = useRenderNode({
    nodeId: id,
    parentPath,
  })

  return (
    <div style={{ display: 'flex', gap: 12 }}>
      {renderChildren(childrenIds)}
    </div>
  )
}

// Register it
const ContainerFactory: ComponentFactory = (id, parentPath) => (
  <Container id={id} parentPath={parentPath} />
)
```

`renderNode` and `renderChildren` apply the full component priority chain: `byNodeId` → `byNodeType` → `components` → dev fallback.

### `useSduiNodeReference`

Subscribe to the live state of other nodes via the `reference` field. When a referenced node changes, this component re-renders:

```tsx
function FeatureFlags({ id }: { id: string }) {
  const { referencedNodes, referencedNodesMap } = useSduiNodeReference({
    nodeId: id,
    schema: z.object({ label: z.string(), enabled: z.boolean() }),
  })

  return (
    <ul>
      {referencedNodes.map((node) => (
        <li key={node.id}>
          {node.state.label}: {node.state.enabled ? 'ON' : 'OFF'}
        </li>
      ))}
    </ul>
  )
}
```

In the document, `reference` points to node IDs:

```json
{
  "id": "flags-panel",
  "type": "FeatureFlags",
  "reference": ["flag-dark-mode", "flag-beta", "flag-notifications"]
}
```

### `useRevelioAction`

Available in any component rendered by `useRevelioDocument`. Gives access to `dispatch` without prop-drilling:

```tsx
// web
import { useRevelioAction } from '@myexamsai/revelio/web'
// native
import { useRevelioAction } from '@myexamsai/revelio/native'

function MyButton({ id }: { id: string }) {
  const { state } = useSduiNodeSubscription({ nodeId: id })
  const { dispatch } = useRevelioAction()

  return (
    <button onClick={() => dispatch({ type: 'button_press', nodeId: id })}>
      {String(state.label)}
    </button>
  )
}
```

### `getNodeSubscriptionData` (non-hook)

Read node data synchronously without subscribing. Useful in event handlers, middleware, or outside React:

```ts
import { getNodeSubscriptionData } from '@myexamsai/revelio'

const data = getNodeSubscriptionData(store, 'node-id', schema)
// data.state, data.childrenIds, data.exists, etc.
```

---

## Actions

Actions are the mechanism for components to communicate back to the server.

### Basic pattern

```tsx
// 1. Handle actions when setting up the hook
const { renderer } = useRevelioDocument({
  url: '/api/sdui/dashboard',
  onAction: async (action) => {
    const res = await fetch('/api/sdui/action', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(action),
    })
    return res.json()
    // Return { document }          → store.updateLayout(document)
    // Return { nodeId, state }     → store.updateNodeState(nodeId, state)
    // Return null | undefined      → no store update
  },
})

// 2. Dispatch from any component
function SubmitButton({ id }: { id: string }) {
  const { state } = useSduiNodeSubscription({ nodeId: id })
  const { dispatch } = useRevelioAction()

  const handlePress = () => {
    dispatch({
      type: 'form_submit',
      formId: 'checkout',
      nodeId: id,
    })
  }

  return <button onClick={handlePress}>{String(state.label)}</button>
}
```

### Optimistic updates

Update the store immediately, before the server responds:

```tsx
function Counter({ id }: { id: string }) {
  const { state } = useSduiNodeSubscription({ nodeId: id, schema: counterSchema })
  const store = useSduiLayoutAction()
  const { dispatch } = useRevelioAction()

  const increment = () => {
    // Optimistic — update immediately
    store.updateNodeState(id, { count: state.count + 1 })

    // Then confirm with server (server response will reconcile)
    dispatch({ type: 'increment', nodeId: id })
  }

  return <button onClick={increment}>{state.count}</button>
}
```

---

## Real-time updates

### Client

```tsx
const { renderer } = useRevelioDocument({
  url: '/api/sdui/dashboard',
  realtime: true,
  // wsUrl: 'wss://api.myapp.com/ws'  // optional override
})
```

The hook opens a WebSocket, receives `SduiLayoutDocument` patches, and calls `store.mergeLayout()` automatically. Reconnects with exponential backoff (1s → 2s → 4s … → 30s max).

### Server (Node.js + `ws`)

Send partial documents. Only the nodes you include get updated — everything else is preserved:

```ts
import { WebSocketServer } from 'ws'

const wss = new WebSocketServer({ server })

wss.on('connection', (ws) => {
  // Send a patch — only nodes in the patch re-render on the client
  function pushUpdate() {
    ws.send(JSON.stringify({
      version: '1.0.0',
      root: {
        id: 'order-count',
        type: 'Stat',
        state: { value: getOrderCount(), label: 'Orders today' },
      },
    }))
  }

  const interval = setInterval(pushUpdate, 5000)
  ws.on('close', () => clearInterval(interval))
})
```

---

## Component overrides

Override a component for a specific node ID or type without modifying the document:

```tsx
<SduiLayoutRenderer
  document={doc}
  components={{ Card: DefaultCardFactory }}
  componentOverrides={{
    byNodeId:   { 'hero-card': HeroCardFactory },   // highest priority
    byNodeType: { Card: AltCardFactory },            // overrides all Cards
  }}
/>
```

Priority order: `byNodeId` → `byNodeType` → `components` → dev warning fallback.

---

## Using `SduiLayoutRenderer` directly

When you manage the store yourself or need full control:

```tsx
import {
  SduiLayoutStore,
  SduiLayoutProvider,
  SduiLayoutRenderer,
} from '@myexamsai/revelio/web'

const store = new SduiLayoutStore(undefined, {
  componentOverrides: {
    Card: (id) => <Card id={id} />,
  },
})

// Load a document
store.updateLayout(myDocument)

// Render
function App() {
  const doc = store.getDocument()
  if (!doc) return null

  return (
    <SduiLayoutProvider store={store}>
      <SduiLayoutRenderer
        document={doc}
        components={{ Card: (id) => <Card id={id} /> }}
        onLayoutChange={(doc) => console.log('layout changed', doc)}
        onError={(err) => console.error(err)}
      />
    </SduiLayoutProvider>
  )
}
```

### `SduiLayoutRenderer` props

| Prop | Type | Required | Description |
|---|---|---|---|
| `document` | `SduiLayoutDocument` | Yes | The document to render |
| `components` | `Record<string, ComponentFactory>` | No | Component map |
| `componentOverrides` | `{ byNodeId?, byNodeType? }` | No | Per-id and per-type overrides |
| `onLayoutChange` | `(doc) => void` | No | Called when layout changes |
| `onError` | `(err) => void` | No | Error boundary callback |

---

## Store API reference

```ts
class SduiLayoutStore {
  // Read
  get state(): SduiLayoutStoreState
  getNodeById(nodeId: string): SduiLayoutNode
  getChildrenIdsById(nodeId: string): string[]
  getLayoutStateById(nodeId: string): Record<string, unknown>
  getAttributesById(nodeId: string): Record<string, unknown>
  getReferenceById(nodeId: string): string | string[] | undefined
  getRootId(): string
  getDocument(): SduiLayoutDocument | null

  // Layout updates
  updateLayout(document: SduiLayoutDocument): void          // full replace
  mergeLayout(document: SduiLayoutDocument,                 // incremental patch
    removeNodeIds?: string[]): void
  resetToInitial(): void                                    // restore first document
  cancelEdit(documentId?: string): void                    // discard, restore cache
  reset(): void                                             // clear everything

  // Node updates
  updateNodeState(nodeId: string, state: Partial<Record<string, unknown>>): void
  updateNodeAttributes(nodeId: string, attributes: Partial<Record<string, unknown>>): void
  updateNodeReference(nodeId: string, reference: string | string[] | undefined): void

  // Variables
  updateVariable(key: string, value: unknown): void
  deleteVariable(key: string): void
  updateVariables(variables: Record<string, unknown>): void

  // Selection (for editor use cases)
  setSelectedNodeId(nodeId?: string): void

  // Subscriptions (used internally by hooks)
  subscribeNode(nodeId: string, callback: () => void): () => void
  subscribeVersion(callback: () => void): () => void
}
```

---

## TypeScript types

All types are exported from `@myexamsai/revelio`:

```ts
import type {
  // Document and node
  SduiLayoutDocument,
  SduiLayoutNode,
  SduiDocument,
  SduiNode,

  // Component system
  ComponentFactory,        // (id: string, parentPath?: string[]) => ReactNode
  RenderNodeFn,            // render function returned by useRenderNode
  ParentPath,              // string[]
  SduiComponentProps,      // { nodeId: string, parentPath?: string[] }

  // Store
  SduiLayoutStoreState,
  SduiLayoutStoreOptions,

  // Normalization internals
  NormalizedSduiEntities,
} from '@myexamsai/revelio'
```

Hook return types:

```ts
import type {
  UseSduiNodeSubscriptionParams,
  GetNodeSubscriptionDataReturn,
  UseRenderNodeParams,
  UseRenderNodeReturn,
  UseSduiNodeReferenceParams,
  ReferencedNodeInfo,
} from '@myexamsai/revelio'
```

`useRevelioDocument` types:

```ts
import type {
  UseRevelioDocumentOptions,
  UseRevelioDocumentReturn,
  RevelioAction,
  RevelioActionResponse,
  RevelioStatus,
} from '@myexamsai/revelio/web'
// or '@myexamsai/revelio/native'
```

---

## Next.js App Router

Revelio ships `'use client'` directives on all hooks and components. Works out of the box with Next.js 13+.

```tsx
// app/dashboard/page.tsx  ← Server Component (fetches doc server-side)
import DashboardClient from './DashboardClient'

export default async function DashboardPage() {
  const doc = await fetch('https://api.myapp.com/sdui/dashboard').then(r => r.json())
  return <DashboardClient initialDocument={doc} />
}

// app/dashboard/DashboardClient.tsx  ← Client Component
'use client'
import { SduiLayoutRenderer, SduiLayoutProvider, SduiLayoutStore } from '@myexamsai/revelio/web'
import { useMemo } from 'react'
import type { SduiLayoutDocument } from '@myexamsai/revelio'

export default function DashboardClient({ initialDocument }: { initialDocument: SduiLayoutDocument }) {
  const store = useMemo(() => {
    const s = new SduiLayoutStore()
    s.updateLayout(initialDocument)
    return s
  }, [initialDocument])

  return (
    <SduiLayoutProvider store={store}>
      <SduiLayoutRenderer
        document={initialDocument}
        components={{ Card: (id) => <Card id={id} /> }}
      />
    </SduiLayoutProvider>
  )
}
```

Or use `useRevelioDocument` for fully client-side fetching — it works identically in Next.js App Router.

---

## Expo / React Native

Tested with Expo SDK 49+ and bare React Native 0.72+. Metro resolves the `react-native` condition automatically.

```tsx
// App.tsx or your navigator screen
import { useRevelioDocument } from '@myexamsai/revelio/native'
import { View, ActivityIndicator, SafeAreaView } from 'react-native'

export default function HomeScreen() {
  const { renderer, status, error } = useRevelioDocument({
    url: 'https://api.myapp.com/sdui/home',
    realtime: true,
    components: {
      Card:    (id) => <Card id={id} />,
      Button:  (id) => <MyButton id={id} />,
    },
    loadingFallback: <ActivityIndicator style={{ flex: 1 }} />,
    errorFallback:   <View><Text>Something went wrong</Text></View>,
  })

  return (
    <SafeAreaView style={{ flex: 1 }}>
      {renderer}
    </SafeAreaView>
  )
}
```

---

## Normalization internals

Revelio normalizes the nested document into a flat map:

```
Input (nested JSON)              Store (flat map)
────────────────────             ─────────────────────────────────────────
{                                nodes = {
  id: 'root',                      'root':    { childrenIds: ['c1', 'c2'] },
  type: 'Container',               'c1':      { state: { count: 0 }, parentId: 'root' },
  children: [                      'c2':      { state: { count: 5 }, parentId: 'root' },
    { id: 'c1', ... },           }
    { id: 'c2', ... },
  ]
}
```

Each component calls `useSduiNodeSubscription({ nodeId })`, which subscribes to `lastModified[nodeId]` via `useSyncExternalStore`. When a node's timestamp changes, only its subscribers fire. Everything else is untouched.

For `mergeLayout` with incremental patches, only the **parent** nodes of changed nodes get a new timestamp — so container components re-render to pick up structural changes (new children), but existing children don't re-render unless their own state changed.

---

## Companion library — `@myexamsai/conjure`

Build `SduiLayoutDocument` objects on your server with a typed fluent API. No JSON by hand:

```bash
npm install @myexamsai/conjure zod
```

```ts
import { defineComponent, buildDocument } from '@myexamsai/conjure'
import { z } from 'zod'

// Define component shapes — share these between server and client
export const Card = defineComponent('Card', z.object({
  title: z.string(),
  subtitle: z.string().optional(),
}))

export const Button = defineComponent('Button', z.object({
  label: z.string(),
  variant: z.enum(['primary', 'secondary', 'ghost']),
}))

// Build a document
const doc = buildDocument({
  metadata: { id: 'dashboard' },
  root: Card.node('hero', {
    title: 'Welcome back',
    subtitle: 'Your summary',
    children: [
      Button.node('cta', { label: 'View orders', variant: 'primary' }),
    ],
  }),
})
```

The Zod schema from `defineComponent` can be imported directly into your frontend component for end-to-end type safety:

```ts
// shared/components.ts — imported by both server and client
export const Card = defineComponent('Card', z.object({ title: z.string() }))

// Frontend
import { Card } from '../shared/components'
const { state } = useSduiNodeSubscription({ nodeId: id, schema: Card.schema })
// state.title → string ✓ — typed from the same schema as the server
```

---

## License

MIT © MyExams AI