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#
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:
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 |
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:
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). 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) |
| A protocol client | rpc.call over the server protocol, events as rpc.event signals |
| A script with a token | POST /rpc/<contract>/<method> on a zidane server |
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.
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>.:
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:
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:
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.
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 |
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). 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.tsxentry cannot importzidanevalues, so it cannot import a file that callsRpc.define. Import the contract's types only, and pass the contract id touseRpc(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.
emitthrows 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.unavailableis normal while the provider loads or reloads. Retry, or watch the Live value.
Related#
- Components:
useRpcanduseStore. - Webview: RPC from a sandboxed page.
- Lifecycle:
dependenciesand load order. - Server protocol: tokens and scopes.