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-node): point users towards a different Node-to-fetch adapter

Mary 0f7d5c5f 4e2306dc

+352 -31
+7 -5
packages/servers/xrpc-server-node/README.md
··· 10 10 details. 11 11 12 12 ```ts 13 - import { serve } from '@hono/node-server'; 13 + import * as http from 'node:http'; 14 + import { createRequestListener } from '@remix-run/node-fetch-server'; 14 15 import { XRPCRouter } from '@atcute/xrpc-server'; 15 16 import { createNodeWebSocket } from '@atcute/xrpc-server-node'; 16 17 ··· 29 30 }, 30 31 }); 31 32 32 - const server = serve({ fetch: router.fetch, port: 3000 }, (info) => { 33 - console.log(`listening on port ${info.port}`); 33 + const server = http.createServer(createRequestListener(router.fetch)); 34 + ws.injectWebSocket(server, router); 35 + 36 + server.listen(3000, () => { 37 + console.log('listening on port 3000'); 34 38 }); 35 - 36 - ws.injectWebSocket(server, router); 37 39 ```
+323 -6
packages/servers/xrpc-server-node/lib/index.test.ts
··· 1 + import * as http from 'node:http'; 2 + 1 3 import { ComAtprotoLabelDefs, ComAtprotoLabelSubscribeLabels } from '@atcute/atproto'; 2 4 import { decode, decodeFirst } from '@atcute/cbor'; 3 - import { XRPCRouter } from '@atcute/xrpc-server'; 5 + import * as v from '@atcute/lexicons/validations'; 6 + import { InvalidRequestError, json, XRPCRouter } from '@atcute/xrpc-server'; 4 7 5 - import { serve, type ServerType } from '@hono/node-server'; 8 + import { createRequestListener } from '@remix-run/node-fetch-server'; 6 9 import { describe, expect, it } from 'vitest'; 7 10 8 11 import { createNodeWebSocket, type NodeWebSocket } from './index.ts'; 9 12 13 + // #region test helpers 14 + 10 15 interface Server extends Disposable { 11 - instance: ServerType; 16 + instance: http.Server; 12 17 port: number; 18 + url: string; 13 19 } 14 20 15 - const createHttpServer = async (router: XRPCRouter, ws: NodeWebSocket): Promise<Server> => { 21 + const createHttpServer = async (router: XRPCRouter, ws?: NodeWebSocket): Promise<Server> => { 16 22 return new Promise((resolve) => { 17 - const instance = serve({ port: 0, fetch: router.fetch }, (addr) => { 23 + const instance = http.createServer(createRequestListener(router.fetch)); 24 + 25 + if (ws) { 18 26 ws.injectWebSocket(instance, router); 27 + } 28 + 29 + instance.listen(0, () => { 30 + const addr = instance.address() as { port: number }; 19 31 20 32 resolve({ 21 33 instance: instance, 22 34 port: addr.port, 35 + url: `http://localhost:${addr.port}`, 23 36 [Symbol.dispose]() { 24 37 instance.close(); 25 38 }, ··· 35 48 return { header, body }; 36 49 }; 37 50 38 - describe('subscriptions', () => { 51 + // #endregion 52 + 53 + // #region test schemas 54 + 55 + const queryNoParams = v.query('com.example.ping', { 56 + params: null, 57 + output: null, 58 + }); 59 + 60 + const queryWithParams = v.query('com.example.greet', { 61 + params: v.object({ 62 + name: v.string(), 63 + excited: v.optional(v.boolean()), 64 + }), 65 + output: { 66 + type: 'lex', 67 + schema: v.object({ 68 + greeting: v.string(), 69 + }), 70 + }, 71 + }); 72 + 73 + const procedureNoParams = v.procedure('com.example.noop', { 74 + params: null, 75 + input: null, 76 + output: null, 77 + }); 78 + 79 + const procedureWithInput = v.procedure('com.example.echo', { 80 + params: null, 81 + input: { 82 + type: 'lex', 83 + schema: v.object({ 84 + message: v.string(), 85 + }), 86 + }, 87 + output: { 88 + type: 'lex', 89 + schema: v.object({ 90 + echo: v.string(), 91 + }), 92 + }, 93 + }); 94 + 95 + const procedureWithParamsAndInput = v.procedure('com.example.create', { 96 + params: v.object({ 97 + collection: v.string(), 98 + }), 99 + input: { 100 + type: 'lex', 101 + schema: v.object({ 102 + text: v.string(), 103 + }), 104 + }, 105 + output: { 106 + type: 'lex', 107 + schema: v.object({ 108 + uri: v.string(), 109 + }), 110 + }, 111 + }); 112 + 113 + // #endregion 114 + 115 + describe('query', () => { 116 + it('handles query with no params', async () => { 117 + const router = new XRPCRouter(); 118 + router.addQuery(queryNoParams, { 119 + async handler() {}, 120 + }); 121 + 122 + using server = await createHttpServer(router); 123 + 124 + const response = await fetch(`${server.url}/xrpc/com.example.ping`); 125 + expect(response.status).toBe(200); 126 + }); 127 + 128 + it('handles query returning json', async () => { 129 + const router = new XRPCRouter(); 130 + router.addQuery(queryWithParams, { 131 + async handler({ params }) { 132 + const greeting = params.excited ? `HELLO ${params.name}!!!` : `hello ${params.name}`; 133 + return json({ greeting }); 134 + }, 135 + }); 136 + 137 + using server = await createHttpServer(router); 138 + 139 + { 140 + const response = await fetch(`${server.url}/xrpc/com.example.greet?name=world`); 141 + expect(response.status).toBe(200); 142 + expect(await response.json()).toEqual({ greeting: 'hello world' }); 143 + } 144 + 145 + { 146 + const response = await fetch(`${server.url}/xrpc/com.example.greet?name=world&excited=true`); 147 + expect(response.status).toBe(200); 148 + expect(await response.json()).toEqual({ greeting: 'HELLO world!!!' }); 149 + } 150 + }); 151 + 152 + it('rejects query with invalid params', async () => { 153 + const router = new XRPCRouter(); 154 + router.addQuery(queryWithParams, { 155 + async handler() { 156 + return json({ greeting: 'unreachable' }); 157 + }, 158 + }); 159 + 160 + using server = await createHttpServer(router); 161 + 162 + const response = await fetch(`${server.url}/xrpc/com.example.greet`); 163 + expect(response.status).toBe(400); 164 + 165 + const body = await response.json(); 166 + expect(body.error).toBe('InvalidRequest'); 167 + }); 168 + 169 + it('rejects query with wrong HTTP method', async () => { 170 + const router = new XRPCRouter(); 171 + router.addQuery(queryNoParams, { 172 + async handler() {}, 173 + }); 174 + 175 + using server = await createHttpServer(router); 176 + 177 + const response = await fetch(`${server.url}/xrpc/com.example.ping`, { method: 'POST' }); 178 + expect(response.status).toBe(405); 179 + }); 180 + 181 + it('returns 404 for undefined routes', async () => { 182 + const router = new XRPCRouter(); 183 + 184 + using server = await createHttpServer(router); 185 + 186 + const response = await fetch(`${server.url}/xrpc/com.example.nonexistent`); 187 + expect(response.status).toBe(404); 188 + }); 189 + 190 + it('handles query throwing XRPCError', async () => { 191 + const router = new XRPCRouter(); 192 + router.addQuery(queryNoParams, { 193 + async handler() { 194 + throw new InvalidRequestError({ description: 'something went wrong' }); 195 + }, 196 + }); 197 + 198 + using server = await createHttpServer(router); 199 + 200 + const response = await fetch(`${server.url}/xrpc/com.example.ping`); 201 + expect(response.status).toBe(400); 202 + expect(await response.json()).toEqual({ 203 + error: 'InvalidRequest', 204 + message: 'something went wrong', 205 + }); 206 + }); 207 + 208 + it('handles query throwing unexpected error', async () => { 209 + const router = new XRPCRouter(); 210 + router.addQuery(queryNoParams, { 211 + async handler() { 212 + throw new Error('boom'); 213 + }, 214 + }); 215 + 216 + using server = await createHttpServer(router); 217 + 218 + const response = await fetch(`${server.url}/xrpc/com.example.ping`); 219 + expect(response.status).toBe(500); 220 + 221 + const body = await response.json(); 222 + expect(body.error).toBe('InternalServerError'); 223 + }); 224 + }); 225 + 226 + describe('procedure', () => { 227 + it('handles procedure with no params or input', async () => { 228 + const router = new XRPCRouter(); 229 + router.addProcedure(procedureNoParams, { 230 + async handler() {}, 231 + }); 232 + 233 + using server = await createHttpServer(router); 234 + 235 + const response = await fetch(`${server.url}/xrpc/com.example.noop`, { method: 'POST' }); 236 + expect(response.status).toBe(200); 237 + }); 238 + 239 + it('handles procedure with json input and output', async () => { 240 + const router = new XRPCRouter(); 241 + router.addProcedure(procedureWithInput, { 242 + async handler({ input }) { 243 + return json({ echo: input.message }); 244 + }, 245 + }); 246 + 247 + using server = await createHttpServer(router); 248 + 249 + const response = await fetch(`${server.url}/xrpc/com.example.echo`, { 250 + method: 'POST', 251 + headers: { 'content-type': 'application/json' }, 252 + body: JSON.stringify({ message: 'hello' }), 253 + }); 254 + 255 + expect(response.status).toBe(200); 256 + expect(await response.json()).toEqual({ echo: 'hello' }); 257 + }); 258 + 259 + it('handles procedure with params and input', async () => { 260 + const router = new XRPCRouter(); 261 + router.addProcedure(procedureWithParamsAndInput, { 262 + async handler({ params, input }) { 263 + return json({ uri: `at://did:plc:test/${params.collection}/abc` }); 264 + }, 265 + }); 266 + 267 + using server = await createHttpServer(router); 268 + 269 + const response = await fetch(`${server.url}/xrpc/com.example.create?collection=app.bsky.feed.post`, { 270 + method: 'POST', 271 + headers: { 'content-type': 'application/json' }, 272 + body: JSON.stringify({ text: 'hello world' }), 273 + }); 274 + 275 + expect(response.status).toBe(200); 276 + expect(await response.json()).toEqual({ 277 + uri: 'at://did:plc:test/app.bsky.feed.post/abc', 278 + }); 279 + }); 280 + 281 + it('rejects procedure with missing required input', async () => { 282 + const router = new XRPCRouter(); 283 + router.addProcedure(procedureWithInput, { 284 + async handler({ input }) { 285 + return json({ echo: input.message }); 286 + }, 287 + }); 288 + 289 + using server = await createHttpServer(router); 290 + 291 + const response = await fetch(`${server.url}/xrpc/com.example.echo`, { method: 'POST' }); 292 + expect(response.status).toBe(400); 293 + 294 + const body = await response.json(); 295 + expect(body.error).toBe('InvalidRequest'); 296 + }); 297 + 298 + it('rejects procedure with wrong content type', async () => { 299 + const router = new XRPCRouter(); 300 + router.addProcedure(procedureWithInput, { 301 + async handler({ input }) { 302 + return json({ echo: input.message }); 303 + }, 304 + }); 305 + 306 + using server = await createHttpServer(router); 307 + 308 + const response = await fetch(`${server.url}/xrpc/com.example.echo`, { 309 + method: 'POST', 310 + headers: { 'content-type': 'text/plain' }, 311 + body: JSON.stringify({ message: 'hello' }), 312 + }); 313 + 314 + expect(response.status).toBe(400); 315 + 316 + const body = await response.json(); 317 + expect(body.error).toBe('InvalidRequest'); 318 + }); 319 + 320 + it('rejects procedure with wrong HTTP method', async () => { 321 + const router = new XRPCRouter(); 322 + router.addProcedure(procedureNoParams, { 323 + async handler() {}, 324 + }); 325 + 326 + using server = await createHttpServer(router); 327 + 328 + const response = await fetch(`${server.url}/xrpc/com.example.noop`, { method: 'GET' }); 329 + expect(response.status).toBe(405); 330 + }); 331 + 332 + it('rejects procedure with invalid input schema', async () => { 333 + const router = new XRPCRouter(); 334 + router.addProcedure(procedureWithInput, { 335 + async handler({ input }) { 336 + return json({ echo: input.message }); 337 + }, 338 + }); 339 + 340 + using server = await createHttpServer(router); 341 + 342 + const response = await fetch(`${server.url}/xrpc/com.example.echo`, { 343 + method: 'POST', 344 + headers: { 'content-type': 'application/json' }, 345 + body: JSON.stringify({ message: 123 }), 346 + }); 347 + 348 + expect(response.status).toBe(400); 349 + 350 + const body = await response.json(); 351 + expect(body.error).toBe('InvalidRequest'); 352 + }); 353 + }); 354 + 355 + describe('subscription', () => { 39 356 it('handles subscription', async () => { 40 357 const ws = createNodeWebSocket(); 41 358 const router = new XRPCRouter({ websocket: ws.adapter });
+2 -1
packages/servers/xrpc-server-node/package.json
··· 31 31 "devDependencies": { 32 32 "@atcute/atproto": "workspace:^", 33 33 "@atcute/cbor": "workspace:^", 34 - "@hono/node-server": "^1.19.9", 34 + "@atcute/lexicons": "workspace:^", 35 + "@remix-run/node-fetch-server": "^0.13.0", 35 36 "@types/node": "^25.3.0", 36 37 "@types/ws": "^8.18.1", 37 38 "vitest": "^4.0.18"
+9 -6
packages/servers/xrpc-server/README.md
··· 79 79 on Deno, Bun or Cloudflare Workers, you can export the router directly and expect it to work out of 80 80 the box. 81 81 82 - for Node.js, you'll need the [`@hono/node-server`][hono-node-server] adapter as the router works 83 - with standard Web Request/Response: 82 + for Node.js, you'll need a fetch-to-Node adapter like 83 + [`@remix-run/node-fetch-server`][node-fetch-server] since the router works with standard Web 84 + Request/Response: 84 85 85 - [hono-node-server]: https://github.com/honojs/node-server 86 + [node-fetch-server]: https://github.com/remix-run/remix/tree/main/packages/node-fetch-server 86 87 87 88 ```ts 89 + import * as http from 'node:http'; 90 + import { createRequestListener } from '@remix-run/node-fetch-server'; 88 91 import { XRPCRouter } from '@atcute/xrpc-server'; 89 - import { serve } from '@hono/node-server'; 90 92 91 93 const router = new XRPCRouter(); 92 94 93 95 // ... add handlers ... 94 96 95 - serve({ fetch: router.fetch, port: 3000 }, (info) => { 96 - console.log(`listening on port ${info.port}`); 97 + const server = http.createServer(createRequestListener(router.fetch)); 98 + server.listen(3000, () => { 99 + console.log('listening on port 3000'); 97 100 }); 98 101 ``` 99 102
+11 -13
pnpm-lock.yaml
··· 1039 1039 '@atcute/cbor': 1040 1040 specifier: workspace:^ 1041 1041 version: link:../../utilities/cbor 1042 - '@hono/node-server': 1043 - specifier: ^1.19.9 1044 - version: 1.19.9(hono@4.12.2) 1042 + '@atcute/lexicons': 1043 + specifier: workspace:^ 1044 + version: link:../../lexicons/lexicons 1045 + '@remix-run/node-fetch-server': 1046 + specifier: ^0.13.0 1047 + version: 0.13.0 1045 1048 '@types/node': 1046 1049 specifier: ^25.3.0 1047 1050 version: 25.3.0 ··· 2068 2071 '@hapi/hoek@11.0.7': 2069 2072 resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} 2070 2073 2071 - '@hono/node-server@1.19.9': 2072 - resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} 2073 - engines: {node: '>=18.14.1'} 2074 - peerDependencies: 2075 - hono: ^4 2076 - 2077 2074 '@inquirer/external-editor@1.0.3': 2078 2075 resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} 2079 2076 engines: {node: '>=18'} ··· 2552 2549 '@prettier/plugin-oxc@0.1.3': 2553 2550 resolution: {integrity: sha512-aABz3zIRilpWMekbt1FL1JVBQrQLR8L4Td2SRctECrWSsXGTNn/G1BqNSKCdbvQS1LWstAXfqcXzDki7GAAJyg==} 2554 2551 engines: {node: '>=14'} 2552 + 2553 + '@remix-run/node-fetch-server@0.13.0': 2554 + resolution: {integrity: sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==} 2555 2555 2556 2556 '@rollup/rollup-android-arm-eabi@4.59.0': 2557 2557 resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} ··· 6105 6105 6106 6106 '@hapi/hoek@11.0.7': {} 6107 6107 6108 - '@hono/node-server@1.19.9(hono@4.12.2)': 6109 - dependencies: 6110 - hono: 4.12.2 6111 - 6112 6108 '@inquirer/external-editor@1.0.3(@types/node@25.3.0)': 6113 6109 dependencies: 6114 6110 chardet: 2.1.1 ··· 6402 6398 '@prettier/plugin-oxc@0.1.3': 6403 6399 dependencies: 6404 6400 oxc-parser: 0.99.0 6401 + 6402 + '@remix-run/node-fetch-server@0.13.0': {} 6405 6403 6406 6404 '@rollup/rollup-android-arm-eabi@4.59.0': 6407 6405 optional: true