Skip to content

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:

KindDeclared withFor
RPC contractRpc.defineMethods with checked inputs and outputs, typed errors and events. Other extensions, the extension's components and webviews, and authenticated protocol clients call it
ContractContract.define<T>A plain object of functions one extension provides and others use, in the same process
RegistryRegistry.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#

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:

FieldTypeDoes
inputSchemaChecks the input. Without it, the method takes none
outputSchemaChecks 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:

TypeScript
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:

MemberHolds
signalAborts when the caller cancels, the implementation is withdrawn, or the call passes the 10 second callback timeout
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:

TypeScript
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:

TypeMeans
rpc.invalid-inputThe input failed the schema
rpc.invalid-outputThe implementation returned something the schema refused
rpc.unavailableNo extension implements the contract now
rpc.abortedThe call was cancelled
rpc.internalThe implementation threw something that is not a declared error
rpc.forbiddenThe 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:

CallerHow
The extension's componentsuseRpc in ui.tsx and tui.tsx (components). Connected to a server, they call with the window's token, like any client
A webviewThe bridge the app injects, for the contract ids its rpc field lists (webview)
A protocol clientrpc.call over the server protocol, events as rpc.event signals
A script with a tokenPOST /rpc/<contract>/<method> on a zidane server
Terminal
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:

StatusMeans
200It worked
400Invalid input
404No extension implements it
422A declared error
499Aborted
500Invalid output or an internal error
401No token
403A 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.

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>.:

TypeScript
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. Read it where you use it:

TypeScript
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:

TypeScript
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.

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')
MemberDoes
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
TypeScript
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:

MemberReturns
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
StatusMeans
pendingThe provider has not loaded yet
activeIt provides the contract. generation grows with each reload
inactiveIt 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). A window's components call through it.

AppNotes
Terminal apptui.tsx calls useRpc(contract). In daemon mode, the calls go to the daemon
Desktop appui.tsx calls through the app's host, as the extension that owns the component
Web appui.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).
  • 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.