a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm

docs(xrpc-server): flesh out the readme

mary.my.id cd1ed4d3 aa4bc029

verified
Changed files
+212 -53
packages
servers
xrpc-server
xrpc-server-bun
xrpc-server-cloudflare
xrpc-server-deno
xrpc-server-node
+8 -6
packages/servers/xrpc-server-bun/README.md
··· 1 1 # @atcute/xrpc-server-bun 2 2 3 - Bun WebSocket adapter for `@atcute/xrpc-server`. 3 + Bun WebSocket adapter for [`@atcute/xrpc-server`](../xrpc-server/). 4 4 5 5 ```sh 6 6 npm install @atcute/xrpc-server-bun 7 7 ``` 8 + 9 + see the [subscriptions section](../xrpc-server/#subscriptions) in the main package for usage details. 8 10 9 11 ```ts 10 12 import { XRPCRouter } from '@atcute/xrpc-server'; 11 13 import { createBunWebSocket } from '@atcute/xrpc-server-bun'; 12 14 13 - import { ComAtprotoSyncSubscribeRepos } from './lexicons/index.js'; 15 + import { ComExampleSubscribe } from './lexicons/index.js'; 14 16 15 - const { adapter, wrap } = createBunWebSocket(); 16 - const router = new XRPCRouter({ websocket: adapter }); 17 + const ws = createBunWebSocket(); 18 + const router = new XRPCRouter({ websocket: ws.adapter }); 17 19 18 - router.addSubscription(ComAtprotoSyncSubscribeRepos.mainSchema, { 20 + router.addSubscription(ComExampleSubscribe.mainSchema, { 19 21 async *handler({ params, signal }) { 20 22 while (!signal.aborted) { 21 23 yield { ··· 25 27 }, 26 28 }); 27 29 28 - export default router satisfies Bun.Serve; 30 + export default ws.wrap(router); 29 31 ```
+5 -3
packages/servers/xrpc-server-cloudflare/README.md
··· 1 1 # @atcute/xrpc-server-cloudflare 2 2 3 - Cloudflare Workers WebSocket adapter for `@atcute/xrpc-server`. 3 + Cloudflare Workers WebSocket adapter for [`@atcute/xrpc-server`](../xrpc-server/). 4 4 5 5 ```sh 6 6 npm install @atcute/xrpc-server-cloudflare 7 7 ``` 8 + 9 + see the [subscriptions section](../xrpc-server/#subscriptions) in the main package for usage details. 8 10 9 11 ```ts 10 12 import { XRPCRouter } from '@atcute/xrpc-server'; 11 13 import { createCloudflareWebSocket } from '@atcute/xrpc-server-cloudflare'; 12 14 13 - import { ComAtprotoSyncSubscribeRepos } from './lexicons/index.js'; 15 + import { ComExampleSubscribe } from './lexicons/index.js'; 14 16 15 17 const adapter = createCloudflareWebSocket(); 16 18 const router = new XRPCRouter({ websocket: adapter }); 17 19 18 - router.addSubscription(ComAtprotoSyncSubscribeRepos.mainSchema, { 20 + router.addSubscription(ComExampleSubscribe.mainSchema, { 19 21 async *handler({ params, signal }) { 20 22 while (!signal.aborted) { 21 23 yield {
+6 -4
packages/servers/xrpc-server-deno/README.md
··· 1 1 # @atcute/xrpc-server-deno 2 2 3 - Deno WebSocket adapter for `@atcute/xrpc-server`. 3 + Deno WebSocket adapter for [`@atcute/xrpc-server`](../xrpc-server/). 4 4 5 5 ```sh 6 - npm install @atcute/xrpc-server-deno 6 + deno add jsr:@aspect/xrpc-server-deno 7 7 ``` 8 8 9 + see the [subscriptions section](../xrpc-server/#subscriptions) in the main package for usage details. 10 + 9 11 ```ts 10 12 import { XRPCRouter } from '@atcute/xrpc-server'; 11 13 import { createDenoWebSocket } from '@atcute/xrpc-server-deno'; 12 14 13 - import { ComAtprotoSyncSubscribeRepos } from './lexicons/index.ts'; 15 + import { ComExampleSubscribe } from './lexicons/index.ts'; 14 16 15 17 const adapter = createDenoWebSocket(); 16 18 const router = new XRPCRouter({ websocket: adapter }); 17 19 18 - router.addSubscription(ComAtprotoSyncSubscribeRepos.mainSchema, { 20 + router.addSubscription(ComExampleSubscribe.mainSchema, { 19 21 async *handler({ params, signal }) { 20 22 while (!signal.aborted) { 21 23 yield {
+12 -14
packages/servers/xrpc-server-node/README.md
··· 1 1 # @atcute/xrpc-server-node 2 2 3 - Node.js WebSocket adapter for `@atcute/xrpc-server`. 3 + Node.js WebSocket adapter for [`@atcute/xrpc-server`](../xrpc-server/). 4 4 5 5 ```sh 6 6 npm install @atcute/xrpc-server-node 7 7 ``` 8 + 9 + see the [subscriptions section](../xrpc-server/#subscriptions) in the main package for usage details. 8 10 9 11 ```ts 10 12 import { serve } from '@hono/node-server'; 11 13 import { XRPCRouter } from '@atcute/xrpc-server'; 12 14 import { createNodeWebSocket } from '@atcute/xrpc-server-node'; 13 15 14 - const { adapter, injectWebSocket } = createNodeWebSocket(); 15 - const router = new XRPCRouter({ websocket: adapter }); 16 + import { ComExampleSubscribe } from './lexicons/index.js'; 17 + 18 + const ws = createNodeWebSocket(); 19 + const router = new XRPCRouter({ websocket: ws.adapter }); 16 20 17 - router.addSubscription(ComAtprotoSyncSubscribeRepos.mainSchema, { 21 + router.addSubscription(ComExampleSubscribe.mainSchema, { 18 22 async *handler({ params, signal }) { 19 23 while (!signal.aborted) { 20 24 yield { ··· 24 28 }, 25 29 }); 26 30 27 - const server = serve( 28 - { 29 - fetch: router.fetch, 30 - port: 3000, 31 - }, 32 - (info) => { 33 - console.log(`Listening on port ${info.port}`); 34 - }, 35 - ); 31 + const server = serve({ fetch: router.fetch, port: 3000 }, (info) => { 32 + console.log(`listening on port ${info.port}`); 33 + }); 36 34 37 - injectWebSocket(server, router); 35 + ws.injectWebSocket(server, router); 38 36 ```
+181 -26
packages/servers/xrpc-server/README.md
··· 6 6 npm install @atcute/xrpc-server 7 7 ``` 8 8 9 - ## quick start 9 + ## prerequisites 10 10 11 11 this framework relies on schemas generated by `@atcute/lex-cli`, you'd need to follow its 12 12 [quick start guide](../../lexicons/lex-cli) on how to set it up. 13 13 14 - for this example, we'll define a very simple query operation, one that returns a message greeting 15 - the name that's provided to it: 14 + for these examples, we'll use a simple query operation that greets a name: 16 15 17 16 ```json 18 17 // file: lexicons/com/example/greet.json ··· 26 25 "type": "params", 27 26 "required": ["name"], 28 27 "properties": { 29 - "name": { 30 - "type": "string" 31 - } 28 + "name": { "type": "string" } 32 29 } 33 30 }, 34 31 "output": { ··· 37 34 "type": "object", 38 35 "required": ["message"], 39 36 "properties": { 40 - "message": { 41 - "type": "string" 42 - } 37 + "message": { "type": "string" } 43 38 } 44 39 } 45 40 } ··· 48 43 } 49 44 ``` 50 45 51 - now we can build a server using the TypeScript schemas: 46 + ## usage 47 + 48 + ### handling requests 49 + 50 + use `addQuery()` for queries (GET) and `addProcedure()` for procedures (POST). handlers receive 51 + typed `params` and `input`, and return responses using the `json()` helper: 52 52 53 53 ```ts 54 - // file: src/index.js 55 54 import { XRPCRouter, json } from '@atcute/xrpc-server'; 56 55 import { cors } from '@atcute/xrpc-server/middlewares/cors'; 57 56 58 - import { ComExampleGreet } from './lexicons/index.js'; 57 + import { ComExampleGreet, ComExampleCreatePost } from './lexicons/index.js'; 59 58 60 59 const router = new XRPCRouter({ middlewares: [cors()] }); 61 60 62 - router.add(ComExampleGreet.mainSchema, { 63 - async handler({ params: { name } }) { 64 - return json({ message: `hello ${name}!` }); 61 + router.addQuery(ComExampleGreet.mainSchema, { 62 + async handler({ params }) { 63 + return json({ message: `hello ${params.name}!` }); 64 + }, 65 + }); 66 + 67 + router.addProcedure(ComExampleCreatePost.mainSchema, { 68 + async handler({ input }) { 69 + const post = await db.createPost(input); 70 + return json(post); 65 71 }, 66 72 }); 67 73 68 74 export default router; 69 75 ``` 70 76 77 + ### serving the router 78 + 71 79 on Deno, Bun or Cloudflare Workers, you can export the router directly and expect it to work out of 72 80 the box. 73 81 74 - but for Node.js, you'll need the [`@hono/node-server`][hono-node-server] adapter as the router works 82 + for Node.js, you'll need the [`@hono/node-server`][hono-node-server] adapter as the router works 75 83 with standard Web Request/Response: 76 84 77 85 [hono-node-server]: https://github.com/honojs/node-server 78 86 79 87 ```ts 80 - // file: src/index.js 81 88 import { XRPCRouter } from '@atcute/xrpc-server'; 82 89 import { serve } from '@hono/node-server'; 83 90 84 91 const router = new XRPCRouter(); 85 92 86 - // ... handler code ... 93 + // ... add handlers ... 94 + 95 + serve({ fetch: router.fetch, port: 3000 }, (info) => { 96 + console.log(`listening on port ${info.port}`); 97 + }); 98 + ``` 99 + 100 + ### error handling 101 + 102 + throw `XRPCError` in handlers to return error responses: 103 + 104 + ```ts 105 + import { XRPCError } from '@atcute/xrpc-server'; 106 + 107 + router.addQuery(ComExampleGetPost.mainSchema, { 108 + async handler({ params, request }) { 109 + const session = await getSession(request); 110 + if (!session) { 111 + throw new XRPCError({ status: 401, error: 'AuthenticationRequired' }); 112 + } 113 + 114 + const post = await db.getPost(params.uri); 115 + if (!post) { 116 + throw new XRPCError({ status: 400, error: 'InvalidRequest', description: `post not found` }); 117 + } 118 + 119 + return json(post); 120 + }, 121 + }); 122 + ``` 123 + 124 + convenience subclasses are also available: `InvalidRequestError`, `AuthRequiredError`, 125 + `ForbiddenError`, `RateLimitExceededError`, `InternalServerError`, `UpstreamFailureError`, 126 + `NotEnoughResourcesError`, `UpstreamTimeoutError`. 127 + 128 + ### subscriptions 129 + 130 + subscriptions provide real-time streaming over WebSocket. they require a runtime-specific adapter: 131 + 132 + | runtime | adapter package | 133 + | ------------------ | -------------------------------------------------------------- | 134 + | Bun | [`@atcute/xrpc-server-bun`](../xrpc-server-bun/) | 135 + | Node.js | [`@atcute/xrpc-server-node`](../xrpc-server-node/) | 136 + | Deno | [`@atcute/xrpc-server-deno`](../xrpc-server-deno/) | 137 + | Cloudflare Workers | [`@atcute/xrpc-server-cloudflare`](../xrpc-server-cloudflare/) | 138 + 139 + here's an example using Bun: 140 + 141 + ```ts 142 + import { XRPCRouter } from '@atcute/xrpc-server'; 143 + import { createBunWebSocket } from '@atcute/xrpc-server-bun'; 144 + 145 + import { ComExampleSubscribe } from './lexicons/index.js'; 146 + 147 + const ws = createBunWebSocket(); 148 + 149 + const router = new XRPCRouter({ websocket: ws.adapter }); 150 + 151 + router.addSubscription(ComExampleSubscribe.mainSchema, { 152 + async *handler({ params, signal }) { 153 + // yield messages until the client disconnects 154 + while (!signal.aborted) { 155 + const events = await getNewEvents(params.cursor); 156 + 157 + for (const event of events) { 158 + yield event; 159 + } 87 160 88 - serve( 89 - { 90 - fetch: router.fetch, 91 - port: 3000, 161 + await sleep(1000); 162 + } 92 163 }, 93 - (info) => { 94 - console.log(`listening on port ${info.port}`); 164 + }); 165 + 166 + export default ws.wrap(router); 167 + ``` 168 + 169 + the handler is an async generator that yields messages. each yielded value is encoded as a CBOR 170 + frame and sent to the client. the `signal` is aborted when the client disconnects. 171 + 172 + for subscription errors, use `XRPCSubscriptionError`: 173 + 174 + ```ts 175 + import { XRPCSubscriptionError } from '@atcute/xrpc-server'; 176 + 177 + router.addSubscription(ComExampleSubscribe.mainSchema, { 178 + async *handler({ params }) { 179 + if (params.cursor && isCursorTooOld(params.cursor)) { 180 + throw new XRPCSubscriptionError({ 181 + error: 'FutureCursor', 182 + description: `cursor is too old`, 183 + }); 184 + } 185 + 186 + // ... 187 + }, 188 + }); 189 + ``` 190 + 191 + ### service authentication 192 + 193 + the `@atcute/xrpc-server/auth` subpackage provides utilities for service-to-service authentication 194 + using JWTs. 195 + 196 + verifying incoming JWTs: 197 + 198 + ```ts 199 + import { AuthRequiredError } from '@atcute/xrpc-server'; 200 + import { ServiceJwtVerifier, type VerifiedJwt } from '@atcute/xrpc-server/auth'; 201 + import { 202 + CompositeDidDocumentResolver, 203 + PlcDidDocumentResolver, 204 + WebDidDocumentResolver, 205 + } from '@atcute/identity-resolver'; 206 + 207 + const jwtVerifier = new ServiceJwtVerifier({ 208 + serviceDid: 'did:web:my-service.example.com', 209 + resolver: new CompositeDidDocumentResolver({ 210 + methods: { 211 + plc: new PlcDidDocumentResolver(), 212 + web: new WebDidDocumentResolver(), 213 + }, 214 + }), 215 + }); 216 + 217 + const verifyServiceAuth = async (request: Request, lxm: string): Promise<VerifiedJwt> => { 218 + const authHeader = request.headers.get('authorization'); 219 + if (!authHeader?.startsWith('Bearer ')) { 220 + throw new AuthRequiredError({ description: `missing or invalid authorization header` }); 221 + } 222 + 223 + const result = await jwtVerifier.verify(authHeader.slice(7), { lxm }); 224 + if (!result.ok) { 225 + throw new AuthRequiredError({ description: result.error.description }); 226 + } 227 + 228 + return result.value; 229 + }; 230 + 231 + router.addQuery(ComExampleProtectedEndpoint.mainSchema, { 232 + async handler({ request }) { 233 + const auth = await verifyServiceAuth(request, 'com.example.protectedEndpoint'); 234 + return json({ caller: auth.issuer }); 95 235 }, 96 - ); 236 + }); 237 + ``` 238 + 239 + creating outgoing JWTs: 240 + 241 + ```ts 242 + import { createServiceJwt } from '@atcute/xrpc-server/auth'; 243 + 244 + const jwt = await createServiceJwt({ 245 + keypair: myServiceKeypair, 246 + issuer: 'did:web:my-service.example.com', 247 + audience: 'did:plc:targetservice', 248 + lxm: 'com.example.someEndpoint', 249 + }); 250 + 251 + // use jwt in Authorization header when calling other services 97 252 ``` 98 253 99 - ## internal calls 254 + ### internal calls 100 255 101 256 you can make typed calls to your own endpoints using `@atcute/client`: 102 257