# Extension RPC and contracts

> Declare typed RPC contracts that other extensions, an extension's UI and protocol clients call, and share APIs and lists between extensions with contracts, registries and Live values.

Extensions talk to each other, and to their own UI, through typed contracts. There are three kinds:

| Kind | Declared with | For |
| - | - | - |
| RPC contract | `Rpc.define` | Methods with checked inputs and outputs, typed errors and events. Other extensions, the extension's components and webviews, and authenticated protocol clients call it |
| Contract | `Contract.define<T>` | A plain object of functions one extension provides and others use, in the same process |
| Registry | `Registry.define<T>` | A list one extension owns and others add items to |

A contract or registry is a token: it carries an id and types, no code. Put the tokens in a file that both sides import. The kitchen-sink example keeps them in `examples/extension-kitchen-sink/contract.ts`.

## Declare an RPC contract

```ts title="contract.ts"
import { Rpc } from 'zidane'
import { z } from 'zod'

export const COUNTER_LIMIT = 1000

export const KitchenRpc = Rpc.define({
  id: 'kitchen-sink',
  methods: {
    bump: {
      input: z.object({ by: z.number().int().min(1).max(100) }),
      output: z.object({ count: z.number() }),
      errors: { 'too-big': z.object({ limit: z.number() }) },
    },
    count: { output: z.number() },
    reset: { output: z.object({ count: z.number() }), scope: 'admin' },
  },
  events: {
    bumped: { schema: z.object({ count: z.number(), by: z.number() }) },
  },
})
```

`id` is the extension's id, or `<extension id>.<name>` for a second contract. A method spec:

| Field | Type | Does |
| - | - | - |
| `input` | `Schema` | Checks the input. Without it, the method takes none |
| `output` | `Schema` | Checks the result. Without it, the method returns nothing |
| `errors` | `{ [name]: Schema }` | Expected failures and their data. Names starting with `rpc.` are reserved |
| `scope` | `'chat' \| 'admin'` | The least token scope a client of a server needs to call it. Default `'chat'`. The app itself, a local connection and other extensions are not limited |

An event spec is `{ schema }`. Event data must be an object. A schema is a Standard Schema (zod, valibot, arktype), which types the values, or a plain JSON Schema, which types them `unknown`.

## Implement it

`ctx.rpc.register(contract, implementation)` implements every method. The contract's id must be the extension's id or start with `<id>.`. From `examples/extension-kitchen-sink/rpc.ts`:

```ts
import { COUNTER_LIMIT, KitchenRpc } from './contract'

let count = 0

const registration = ctx.rpc.register(KitchenRpc, {
  bump: async ({ by }, call) => {
    if (count + by > COUNTER_LIMIT)
      return call.error('too-big', `The counter stops at ${COUNTER_LIMIT}.`, { limit: COUNTER_LIMIT })
    count += by
    registration.events.emit('bumped', { count, by })
    return { count }
  },
  count: () => count,
  reset: () => {
    count = 0
    registration.events.emit('bumped', { count: 0, by: 0 })
    return { count: 0 }
  },
})
```

Each method gets its input and a call context:

| Member | Holds |
| - | - |
| `signal` | Aborts when the caller cancels, the implementation is withdrawn, or the call passes the 10 second [callback timeout](https://zidane.si/docs/extensions-lifecycle.md#guards) |
| `caller` | `{ kind: 'extension', id }` (another extension), `{ kind: 'ui' }` (the extension's own components or webview, when the app runs the extension itself) or `{ kind: 'client', clientId }` (a protocol or HTTP client, windows of a daemon or a server included) |
| `error(type, message, data)` | Builds a declared error, typed from `errors`. Return it or throw it |

`registration.events.emit(name, data)` sends an event to every subscriber. `registration.dispose()` withdraws the implementation.

## Call it

`ctx.rpc.client(contract)` returns a typed client, for this extension's contract or another's:

```ts
import { RpcCallError } from 'zidane'
import { KitchenRpc } from './contract'

const rpc = ctx.rpc.client(KitchenRpc)

try {
  const { count } = await rpc.bump({ by: 5 })
  ctx.logger.info('count', { count })
}
catch (error) {
  if (error instanceof RpcCallError && error.type === 'too-big')
    ctx.logger.warn(error.message, { data: error.data })
  else
    throw error
}

rpc.events.on('bumped', (event) => {
  ctx.logger.info('bumped', { count: event.data.count, in: event.location.directory })
})
```

Inputs are checked before the call and outputs after it, on both sides. A method rejects with `RpcCallError`, whose `type` is a declared error name or one of:

| Type | Means |
| - | - |
| `rpc.invalid-input` | The input failed the schema |
| `rpc.invalid-output` | The implementation returned something the schema refused |
| `rpc.unavailable` | No extension implements the contract now |
| `rpc.aborted` | The call was cancelled |
| `rpc.internal` | The implementation threw something that is not a declared error |
| `rpc.forbidden` | The caller's token is below the method's `scope` |

The client's `events` has `on(name, listener)`, which returns a `Disposable`, and `subscribe(name)`, an async iterable. An event is `{ type: 'rpc.<contract>.<event>', data, location: { directory } }`.

## From the UI and clients

The same contract serves every caller:

| Caller | How |
| - | - |
| The extension's components | `useRpc` in `ui.tsx` and `tui.tsx` ([components](https://zidane.si/docs/extensions-components.md)). Connected to a server, they call with the window's token, like any client |
| A webview | The bridge the app injects, for the contract ids its `rpc` field lists ([webview](https://zidane.si/docs/extensions-webview.md)) |
| A protocol client | `rpc.call` over the [server protocol](https://zidane.si/docs/server-protocol.md), events as `rpc.event` signals |
| A script with a token | `POST /rpc/<contract>/<method>` on a [zidane server](https://zidane.si/docs/server.md) |

```sh
curl -X POST "https://zidane.example.com/rpc/kitchen-sink/bump?cwd=/srv/repo" \
  -H "Authorization: Bearer $ZIDANE_TOKEN" \
  -d '{"by": 2}'
```

The body is the method's JSON input (empty for none). `?cwd=` picks the project whose extensions answer. The response is the call's envelope, `{ ok: true, output, meta }` or `{ ok: false, error: { type, message, data }, meta }`, with a status:

| Status | Means |
| - | - |
| 200 | It worked |
| 400 | Invalid input |
| 404 | No extension implements it |
| 422 | A declared error |
| 499 | Aborted |
| 500 | Invalid output or an internal error |
| 401 | No token |
| 403 | A token that may not call RPC at all (a machine token, or a scope below `chat`), or a token below the method's `scope` (`rpc.forbidden` in the envelope) |

Each method's `scope` is checked against the caller's token, over the protocol and over HTTP: a `chat` token (what a web window or a chat-only script holds) reaches `chat` methods, an `admin` token all of them. A call above the token answers `rpc.forbidden` before the method runs. Your own components connected to a server call with their window's token, so give a method that changes something sensitive `scope: 'admin'`, and do not trust any caller of a `chat` method more than a chat client.

## Contracts

A contract is an API one extension provides to others in the same process: an object of functions, with no schema checks.

```ts title="contract.ts"
import { Contract } from 'zidane'

export interface KitchenApi {
  greet: (name: string) => string
  count: () => number
}
export const KitchenApiContract = Contract.define<KitchenApi>('kitchen-sink.api')
```

The provider calls `ctx.contracts.provide(token, impl)`. The id must be the extension's id or start with `<id>.`:

```ts
import { KitchenApiContract } from './contract'

ctx.contracts.provide(KitchenApiContract, {
  greet: name => `Hello, ${name}!`,
  count: () => 0,
})
```

A user calls `ctx.contracts.use(token)`, which returns a [Live accessor](#live-values). Read it where you use it:

```ts
import { KitchenApiContract } from './contract'

const kitchen = ctx.contracts.use(KitchenApiContract)

ctx.commands.register({
  id: 'greet',
  title: 'Greet from the kitchen',
  run: () => {
    const api = kitchen.value()
    ctx.logger.info(api ? api.greet('Ada') : 'kitchen-sink is not running')
  },
})
```

Add the provider to `dependencies`, so it loads first and installs show the hint:

```ts
import { defineExtension } from 'zidane'
import { KitchenApiContract } from './contract'

export default defineExtension({
  apiVersion: 1,
  id: 'kitchen-fan',
  dependencies: { 'kitchen-sink': '^1.0.0' },
  setup(ctx) {
    ctx.logger.info('kitchen-sink is', { status: ctx.contracts.use(KitchenApiContract).get().status })
  },
})
```

## Registries

A registry is a list one extension owns and any extension adds to. Plugin points (recipes, linters, sources) are registries.

```ts title="contract.ts"
import { Registry } from 'zidane'

export interface Recipe {
  id: string
  title: string
  steps: readonly string[]
}
export const Recipes = Registry.define<Recipe>('kitchen-sink.recipes')
```

| Member | Does |
| - | - |
| `ctx.contracts.add(registry, item)` | Adds an item. Returns a `Disposable`. Any extension can add, the owner included |
| `ctx.contracts.list(registry)` | A `Readable` of every item, in load order. Only the owner, the extension whose id prefixes the registry's, reads it |

```ts
import { Recipes } from './contract'

// In any extension:
ctx.contracts.add(Recipes, { id: 'tea', title: 'Tea', steps: ['Boil water', 'Steep 3 minutes'] })

// In kitchen-sink, the owner:
const recipes = ctx.contracts.list(Recipes)
recipes.subscribe(items => ctx.logger.info('recipes', { count: items.length }))
```

An item goes when the extension that added it unloads.

## Live values

What another extension provides changes as it starts, stops and reloads. `ctx.contracts.use` returns a `LiveAccessor`:

| Member | Returns |
| - | - |
| `get()` | `{ status: 'pending' }`, `{ status: 'active', value, generation }` or `{ status: 'inactive', reason? }` |
| `value()` | The value when active, else `undefined` |
| `subscribe(listener)` | Calls `listener(live)` after each change. Returns the unsubscribe |

| Status | Means |
| - | - |
| `pending` | The provider has not loaded yet |
| `active` | It provides the contract. `generation` grows with each reload |
| `inactive` | It is missing, off, failed, or stopped providing. `reason` says why |

Do not keep `value()` across awaits or events: after a reload of the provider, the old object belongs to an unloaded extension. `use` returns the same accessor for every call with one token.

## In each app

RPC runs in the process that runs the extensions ([where the code runs](https://zidane.si/docs/extensions.md#where-the-code-runs)). A window's components call through it.

| App | Notes |
| - | - |
| Terminal app | `tui.tsx` calls `useRpc(contract)`. In daemon mode, the calls go to the daemon |
| Desktop app | `ui.tsx` calls through the app's host, as the extension that owns the component |
| Web app | `ui.tsx` and webviews call the server. Scripts call `POST /rpc/…` on the server |

The HTTP route exists on a zidane server only. The local daemon has no HTTP port: protocol clients call it with `rpc.call`.

## Pitfalls

- A `ui.tsx` entry cannot import `zidane` values, so it cannot import a file that calls `Rpc.define`. Import the contract's types only, and pass the contract id to `useRpc` ([components](https://zidane.si/docs/extensions-components.md)).
- The contract's id must start with the extension's id. Registering another extension's contract throws.
- An event's data must be an object, not a number or a string. `emit` throws otherwise, and when the data fails the event's schema.
- A method may not be named `events`: the client uses that name.
- A contract has no checks: whatever the provider passes reaches the caller. Use RPC when the caller is untrusted or remote.
- `rpc.unavailable` is normal while the provider loads or reloads. Retry, or watch the Live value.

## Related

- [Components](https://zidane.si/docs/extensions-components.md): `useRpc` and `useStore`.
- [Webview](https://zidane.si/docs/extensions-webview.md): RPC from a sandboxed page.
- [Lifecycle](https://zidane.si/docs/extensions-lifecycle.md#load-order): `dependencies` and load order.
- [Server protocol](https://zidane.si/docs/server-protocol.md): tokens and scopes.
