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

Configure Feed

Select the types of activity you want to include in your feed.

docs(xrpc-server): flesh out the readme

Mary cd1ed4d3 aa4bc029

+212 -53
+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