npm package: @myexamsai/conjure
Description: Conjure — Type-safe server-side document builder for @myexamsai/revelio. Define components with Zod schemas, compose them into SduiLayoutDocuments with a fluent API, and push real-time patches — all fully typed.
Latest version: 1.0.0

# Conjure ✨

> *"Conjure it on the server, Revelio reveals it on the client."*

**Conjure** is the server-side companion to [`@myexamsai/revelio`](https://www.npmjs.com/package/@myexamsai/revelio). It gives you a type-safe, fluent API to build `SduiLayoutDocument` objects in Node.js — no hand-writing JSON, no runtime surprises.

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

---

## How it fits with Revelio

```
Server (Node.js)                    Client (React / React Native)
────────────────                    ──────────────────────────────
@myexamsai/conjure                  @myexamsai/revelio

defineComponent('Card', schema) ──► shared/components.ts ◄── schema: Card.schema
       │                                                        (useSduiNodeSubscription)
       ▼
buildDocument({ root: Card.node(...) })
       │
       ▼ JSON
   res.json(doc) ──────────────────► useRevelioDocument({ url })
                                           │
                                           ▼
                                    <Card id="hero" />  ← your component
```

The Zod schema defined in Conjure is the **single source of truth** shared between server and client. The server uses it to build nodes. The client uses it to validate and type state. One definition, zero duplication.

---

## Quick start

### 1. Define your components (shared file)

```ts
// shared/components.ts — imported by BOTH server and client
import { defineComponent } from '@myexamsai/conjure'
import { z } from 'zod'

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', 'danger']),
  action: z.record(z.unknown()).optional(),
  disabled: z.boolean().optional(),
}))

export const Stat = defineComponent('Stat', z.object({
  label: z.string(),
  value: z.string(),
  delta: z.string().optional(),
  deltaVariant: z.enum(['success', 'danger', 'neutral']).optional(),
}))
```

### 2. Build a document on the server

```ts
// api/sdui/dashboard.ts
import { buildDocument, actions } from '@myexamsai/conjure'
import { Card, Button, Stat } from '../shared/components'

export async function GET(req: Request) {
  const orders = await db.orders.findMany({ limit: 5 })

  const doc = buildDocument({
    metadata: { id: 'dashboard', name: 'Dashboard' },
    root: Card.node('root', {
      title: 'Welcome back',
      subtitle: `You have ${orders.length} new orders`,
    }, {
      children: [
        Stat.node('revenue', {
          label: 'Revenue',
          value: '$48,200',
          delta: '+12%',
          deltaVariant: 'success',
        }),
        Button.node('view-orders', {
          label: 'View all orders',
          variant: 'primary',
          action: actions.navigate('orders'),
        }),
      ],
    }),
  })

  return Response.json(doc)
}
```

### 3. Use the schema on the client (type-safe)

```tsx
// components/Card.tsx
import { useSduiNodeSubscription } from '@myexamsai/revelio'
import { Card as CardDescriptor } from '../shared/components'

export function Card({ id }: { id: string }) {
  // state is fully typed — string, string | undefined — from the same schema
  const { state, childrenIds } = useSduiNodeSubscription({
    nodeId: id,
    schema: CardDescriptor.schema,
  })

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

---

## API reference

### `defineComponent(type, schema)`

Creates a typed component descriptor. The core of Conjure.

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

const MyComponent = defineComponent('MyComponent', z.object({
  title: z.string(),
  count: z.number().default(0),
  tags: z.array(z.string()).optional(),
  status: z.enum(['active', 'inactive', 'pending']),
  metadata: z.record(z.unknown()).optional(),
}))
```

#### Returns: `ComponentDescriptor`

**`.type`** — the type string, e.g. `'MyComponent'`

**`.schema`** — the Zod schema. Import this on the client and pass to `useSduiNodeSubscription`.

**`.node(id, state, opts?)`** — create a fully typed node:

```ts
MyComponent.node('my-node', {
  title: 'Hello',
  count: 5,
  status: 'active',
})

// With children, attributes, reference
MyComponent.node('my-node', { title: 'Hello', status: 'active' }, {
  children: [Button.node('btn', { label: 'Click', variant: 'primary' })],
  attributes: { padding: 16 },
  reference: ['other-node-id'],
})
```

TypeScript will error if you pass the wrong state shape. Wrong key name, wrong type, missing required field — all caught at compile time.

**`.patch(id, state)`** — create a partial node for `mergeLayout`. State is `Partial<schema>`:

```ts
// Only update subtitle — title is preserved in the store
MyComponent.patch('my-node', { count: 10 })
```

---

### `buildDocument(options)`

Compose a root node and metadata into a complete document ready for `res.json()`.

```ts
import { buildDocument } from '@myexamsai/conjure'

const doc = buildDocument({
  // Required
  root: Card.node('root', { title: 'Hello', subtitle: 'World' }),

  // Optional
  metadata: {
    id: 'my-screen',          // Used by Revelio for document identity detection
    name: 'My Screen',
    description: 'A screen',
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    author: 'server',
  },

  // Optional — global variables accessible to all nodes
  variables: {
    userId: user.id,
    theme: 'dark',
    locale: 'en-US',
  },

  // Optional — defaults to '1.0.0'
  version: '1.0.0',
})

res.json(doc)
```

---

### `patch(node, opts?)`

Build a partial document for `mergeLayout` or WebSocket pushes.

```ts
import { patch } from '@myexamsai/conjure'

// Update a single node
const update = patch(Stat.patch('revenue', { value: '$52k', delta: '+8%' }))
ws.send(JSON.stringify(update))

// Update a subtree and remove some nodes
const update2 = patch(
  OrderList.node('orders', { items: freshItems }),
  { removeNodeIds: ['order-42', 'order-43'] }
)
ws.send(JSON.stringify(update2))
```

On the client, `mergeLayout` handles the patch surgically — only the nodes in the patch are updated. Everything else is untouched.

---

### `actions.*`

Typed action builders for interactive nodes. Store actions in node state — the client reads them and calls `dispatch(action)` on interaction.

```ts
import { actions } from '@myexamsai/conjure'

// Navigate to a screen
actions.navigate('order-detail', { orderId: '123' })
// → { type: 'navigate', screen: 'order-detail', params: { orderId: '123' } }

// Submit a form
actions.submitForm('checkout')
// → { type: 'submit_form', formId: 'checkout' }

// Update a node
actions.updateNode('counter-1', { count: 5 })
// → { type: 'update_node', nodeId: 'counter-1', state: { count: 5 } }

// Dismiss a modal
actions.dismissModal('confirm-dialog')
// → { type: 'dismiss_modal', modalId: 'confirm-dialog' }

// Open a URL
actions.openUrl('https://myexams.ai', '_blank')
// → { type: 'open_url', url: 'https://myexams.ai', target: '_blank' }

// Copy to clipboard
actions.copy('invite-code-XYZ')
// → { type: 'copy', text: 'invite-code-XYZ' }

// Refresh the current document
actions.refresh()
// → { type: 'refresh' }

// Custom action — for anything not covered above
actions.custom('toggle_flag', { flag: 'dark_mode', value: true })
// → { type: 'toggle_flag', payload: { flag: 'dark_mode', value: true } }
```

Use in a node:

```ts
Button.node('submit', {
  label: 'Place order',
  variant: 'primary',
  action: actions.submitForm('checkout'),
})
```

On the client, your `onAction` handler receives the action:

```ts
onAction: async (action) => {
  if (action.type === 'submit_form') {
    const res = await submitForm(action.formId as string)
    return { document: res.document }
  }
  if (action.type === 'navigate') {
    router.push(`/${action.screen}`)
  }
}
```

---

### `node(type, id, state, opts?)` / `nodeWithChildren(...)`

Raw node builders for when you don't have a `ComponentDescriptor`. State is untyped.

```ts
import { node, nodeWithChildren } from '@myexamsai/conjure'

// Simple node
node('Container', 'wrapper', {})

// With all options
node('Container', 'wrapper', { padding: 16 }, {
  children: [Card.node('hero', { title: 'Hello' })],
  attributes: { flex: 1 },
  reference: 'some-other-node',
})

// Shorthand for containers
nodeWithChildren('Container', 'wrapper', {}, [
  Card.node('hero', { title: 'Hello' }),
  Button.node('cta', { label: 'Go', variant: 'primary' }),
])
```

---

### `validate(component, nodeId, state)` / `safeValidate(...)`

Runtime validation against a component's schema. Useful in tests, CI, and development request handlers.

```ts
import { validate, safeValidate, validateDocument, assertDocument } from '@myexamsai/conjure'

// Throws TypeError if invalid
validate(Card, 'hero', { title: 'Hello' })          // ✓ passes, returns typed data
validate(Card, 'hero', { title: 42 })               // ✗ throws TypeError

// Returns a result object — never throws
const result = safeValidate(Card, 'hero', { title: 'Hello' })
if (result.success) {
  console.log(result.data.title)  // string ✓
} else {
  console.error(result.issues)    // [{ path: 'title', message: 'Expected string...' }]
}

// Validate an entire document tree
const docResult = validateDocument(doc, { Card, Button, Stat })
if (!docResult.success) {
  docResult.issues.forEach(i =>
    console.error(`${i.nodeId} (${i.nodeType}) ${i.path}: ${i.message}`)
  )
}

// Assert an entire document — throws on first failure
assertDocument(doc, { Card, Button, Stat })
res.json(doc)  // only reached if valid
```

---

## Patterns

### Sharing schemas between server and client

The key pattern that makes Conjure powerful:

```ts
// shared/components.ts
import { defineComponent } from '@myexamsai/conjure'
import { z } from 'zod'

export const ProductCard = defineComponent('ProductCard', z.object({
  name: z.string(),
  price: z.number(),
  imageUrl: z.string().url(),
  inStock: z.boolean(),
  rating: z.number().min(0).max(5).optional(),
}))
```

```ts
// server/api/products.ts
import { buildDocument } from '@myexamsai/conjure'
import { ProductCard } from '../../shared/components'

const doc = buildDocument({
  root: ProductCard.node('product-1', {
    name: 'Exam Prep Course',
    price: 29.99,
    imageUrl: 'https://cdn.myexams.ai/course.jpg',
    inStock: true,
    rating: 4.8,
  }),
})
```

```tsx
// client/components/ProductCard.tsx
import { useSduiNodeSubscription } from '@myexamsai/revelio'
import { ProductCard as ProductCardDescriptor } from '../../shared/components'

export function ProductCard({ id }: { id: string }) {
  const { state } = useSduiNodeSubscription({
    nodeId: id,
    schema: ProductCardDescriptor.schema,
  })
  // state.name → string ✓
  // state.price → number ✓
  // state.rating → number | undefined ✓
}
```

### Real-time updates with WebSocket

```ts
// server/ws.ts
import { WebSocketServer } from 'ws'
import { patch } from '@myexamsai/conjure'
import { Stat } from '../shared/components'

const wss = new WebSocketServer({ server })

wss.on('connection', (ws) => {
  const timer = setInterval(async () => {
    const revenue = await getRevenue()

    ws.send(JSON.stringify(
      patch(Stat.patch('revenue', {
        value: formatCurrency(revenue),
        delta: formatDelta(revenue),
        deltaVariant: revenue > 0 ? 'success' : 'danger',
      }))
    ))
  }, 5000)

  ws.on('close', () => clearInterval(timer))
})
```

### Handling actions on the server

```ts
// server/api/action.ts
import { Button } from '../shared/components'

app.post('/api/sdui/action', async (req, res) => {
  const action = req.body

  switch (action.type) {
    case 'navigate':
      // Client handles navigation — no server response needed
      return res.json(null)

    case 'submit_form':
      const result = await processForm(req.body)
      return res.json({
        // Return a full document to replace the layout
        document: buildDocument({ root: SuccessScreen.node('root', { message: 'Order placed!' }) }),
      })

    case 'update_node':
      // Return a node patch to update one node
      return res.json({
        nodeId: action.nodeId,
        state: { count: (action.state as any).count + 1 },
      })

    default:
      return res.json(null)
  }
})
```

### Validation in CI / tests

```ts
// tests/document.test.ts
import { buildDocument, assertDocument } from '@myexamsai/conjure'
import { Card, Button } from '../shared/components'

test('dashboard document is valid', () => {
  const doc = buildDocument({
    root: Card.node('root', { title: 'Hello' }, {
      children: [Button.node('cta', { label: 'Go', variant: 'primary' })],
    }),
  })

  // Throws if any node fails schema validation
  expect(() => assertDocument(doc, { Card, Button })).not.toThrow()
})
```

---

## TypeScript

All types are exported:

```ts
import type {
  // Core
  ComponentDescriptor,
  NodeOptions,
  BuildDocumentOptions,
  PatchOptions,
  ConjurePatchDocument,

  // Schema
  ConjureNode,
  ConjureDocument,
  ConjureDocumentMetadata,

  // Actions
  ConjureAction,
  NavigateAction,
  SubmitFormAction,
  UpdateNodeAction,
  DismissModalAction,
  OpenUrlAction,
  CopyAction,
  RefreshAction,
  CustomAction,

  // Validation
  ValidateResult,
  ValidateSuccess,
  ValidateFailure,
} from '@myexamsai/conjure'
```

---

## License

MIT © MyExams AI