Skip to content

Commit 2db3500

Browse files
committed
feat: add RPC layer with protocol definition and example usage
- Introduced a new RPC layer in `iso-ucan` for request/response handling. - Added protocol definition for Todo commands, including listing, adding, and completing todos. - Provided example server and client implementations using Hono and in-memory storage. - Ensured type safety and error handling through shared receipt schemas.
1 parent 9e3f738 commit 2db3500

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

packages/iso-ucan/readme.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,136 @@ exp: nowInSeconds + 1000,
7676
})
7777
```
7878

79+
## RPC
80+
81+
`iso-ucan/rpc` is a small request/response layer on top of UCAN
82+
invocations. You declare a protocol once as a record of commands
83+
(`defineCommand`), then build matching client and server pairs from it
84+
(`defineClient` / `defineServer`). Every command's success and error shapes
85+
are described by a single receipt schema (`receipt`, `receiptResult`,
86+
`receiptError`) which is shared by both ends, so the call sites are fully
87+
type-safe and discriminated unions just work on the client.
88+
89+
Internal server failures (invalid invocation, unknown command, handler
90+
threw, …) are surfaced as a generic `SERVER_ERROR` variant that
91+
`receipt(...)` adds implicitly, so clients can always handle them with one
92+
narrow.
93+
94+
### Define a shared protocol
95+
96+
```ts
97+
import {
98+
defineCommand,
99+
receipt,
100+
receiptError,
101+
receiptResult,
102+
} from 'iso-ucan/rpc'
103+
import { z } from 'zod'
104+
105+
const TodoSchema = z.object({
106+
id: z.string(),
107+
text: z.string(),
108+
done: z.boolean(),
109+
})
110+
111+
export const Protocol = {
112+
TodoList: defineCommand({
113+
cmd: '/todo/list',
114+
args: z.object({}),
115+
receipt: receipt(receiptResult(z.object({ todos: z.array(TodoSchema) }))),
116+
}),
117+
TodoAdd: defineCommand({
118+
cmd: '/todo/add',
119+
args: z.object({ text: z.string().min(1) }),
120+
receipt: receipt(receiptResult(z.object({ todo: TodoSchema }))),
121+
}),
122+
TodoComplete: defineCommand({
123+
cmd: '/todo/complete',
124+
args: z.object({ id: z.string() }),
125+
receipt: receipt(
126+
receiptResult(z.object({ todo: TodoSchema })),
127+
receiptError('NOT_FOUND', 'Todo not found.', z.object({ id: z.string() }))
128+
),
129+
}),
130+
} as const
131+
```
132+
133+
### Server (e.g. Hono + `@hono/node-server`)
134+
135+
```ts
136+
import { serve } from '@hono/node-server'
137+
import { Hono } from 'hono'
138+
import { MemoryDriver } from 'iso-kv/drivers/memory.js'
139+
import { defineServer, type ServerHandlers } from 'iso-ucan/rpc'
140+
import { Store } from 'iso-ucan/store'
141+
import { Protocol } from './protocol.ts'
142+
143+
const store = new Store(new MemoryDriver())
144+
// ...seed `store` with the delegations that authorise your clients...
145+
146+
const handlers: ServerHandlers<typeof Protocol> = {
147+
'/todo/list': ({ invocation }) => ({
148+
cid: invocation.cid,
149+
result: { todos: [/* ... */] },
150+
}),
151+
'/todo/add': ({ args, invocation }) => ({
152+
cid: invocation.cid,
153+
result: { todo: { id: '1', text: args.text, done: false } },
154+
}),
155+
'/todo/complete': ({ args, invocation }) => {
156+
// return either { cid, result: ... } or { cid, error: { code, message, data } }
157+
return {
158+
cid: invocation.cid,
159+
error: {
160+
code: 'NOT_FOUND',
161+
message: 'Todo not found.',
162+
data: { id: args.id },
163+
},
164+
}
165+
},
166+
}
167+
168+
const rpc = defineServer(Protocol, {
169+
signer: serverSigner,
170+
store,
171+
verifierResolver,
172+
handlers,
173+
})
174+
175+
const app = new Hono()
176+
app.post('/rpc', (c) => rpc(c.req.raw))
177+
serve({ fetch: app.fetch, port: 3000 })
178+
```
179+
180+
### Client
181+
182+
```ts
183+
import { defineClient } from 'iso-ucan/rpc'
184+
185+
const client = defineClient(Protocol, {
186+
url: 'http://localhost:3000/rpc',
187+
issuer: cliSigner,
188+
audience: serverSigner.didObject,
189+
store,
190+
verifierResolver,
191+
})
192+
193+
// `args` and the returned receipt are typed from the protocol entry for `cmd`.
194+
const r = await client.request({ cmd: '/todo/complete', args: { id: '42' } })
195+
196+
if ('result' in r) {
197+
console.log(r.result.todo)
198+
} else if (r.error.code === 'NOT_FOUND') {
199+
console.error(r.error.message, r.error.data) // data: { id: string }
200+
} else {
201+
// r.error.code === 'SERVER_ERROR' — always present in every receipt union.
202+
console.error('server error:', r.error.message)
203+
}
204+
```
205+
206+
A complete runnable example (Hono server + a small CLI client) lives in
207+
[`examples/rpc-todo`](../../examples/rpc-todo).
208+
79209
## License
80210

81211
MIT © [Hugo Dias](http://hugodias.me)

0 commit comments

Comments
 (0)