Demonstration bridge between ATproto and GraphQL. Generate schema types and interface with the ATmosphere via GraphQL queries. Includes a TypeScript server with IDE.
2
fork

Configure Feed

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

feat(xrpc): support xRPC calls via GraphiQL, npm run server, npm run generate, lex integration

Tim Ryan b17b496f 081065af

+300 -116
+50 -10
README.md
··· 5 5 **Installation:** 6 6 7 7 ``` 8 - git submodule update --init --recursive 9 8 npm install 10 9 ``` 11 10 12 11 ## Schema Generation 13 12 14 - This folder contains scripts to generate a GraphQL schema from Lexicon. Standard ATProto definitions are initialized in the `deps/atproto` folder. 13 + This project uses [@atproto/lex](https://www.npmjs.com/package/@atproto/lex-cli) to download Lexicons and generate xRPC calls that are used in the backend. 15 14 16 15 Example usage: 17 16 18 17 ``` 19 - ts-lex install app.bsky.actor.getProfile \ 20 - com.atproto.server.getSession 21 - npx tsx src/bin/main.ts \ 22 - app.bsky.actor.getProfile \ 23 - com.atproto.server.getSession \ 24 - -o schema-generated.graphql 18 + ts-lex install app.bsky.actor.getProfile com.atproto.server.getSession 19 + npm run generate 25 20 ``` 26 21 27 22 This will generate schema definitions for the `getProfile` and `getSession` procedures and recursively import all their referenced types, e.g. ··· 64 59 This repo also contains a Rust GraphQL server that can bridge an exported schema to the AT protocol. You can use this as a reference or even expand it to include non-AT GraphQL types. 65 60 66 61 - GraphQL API endpoint at `/graphql` 67 - - Interactive GraphiQL interface for development 62 + - Interactive GraphiQL interface for development at `/graphiql` 68 63 69 64 ### Building 70 65 ··· 72 67 npm run server 73 68 ``` 74 69 75 - The server will start on `http://localhost:8000`. 70 + The server will start on `http://localhost:8000`. An example query and variables: 71 + 72 + ``` 73 + # Query 74 + query ($actor: ID!) { 75 + lexicon { 76 + app { 77 + bsky { 78 + actor { 79 + getProfile(actor: $actor) { 80 + avatar 81 + postsCount 82 + description 83 + displayName 84 + } 85 + } 86 + } 87 + } 88 + } 89 + } 90 + 91 + # Variables 92 + { 93 + "actor": "did:plc:olka44iewlycp4vxa6srsabp" 94 + } 95 + 96 + # Response 97 + { 98 + "data": { 99 + "lexicon": { 100 + "app": { 101 + "bsky": { 102 + "actor": { 103 + "getProfile": { 104 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:olka44iewlycp4vxa6srsabp/bafkreicozgr2pxfq5cdnfxlhrc6si3sunraekbp2pccqukvh6dapr4en5i", 105 + "postsCount": 374, 106 + "description": "browser tab collector\nnon-recurring engineer\nsomerville, ma", 107 + "displayName": "tim ryan" 108 + } 109 + } 110 + } 111 + } 112 + } 113 + } 114 + } 115 + ``` 76 116 77 117 ## License 78 118
+111
package-lock.json
··· 17 17 }, 18 18 "devDependencies": { 19 19 "@atproto/lex": "^0.0.23", 20 + "@atproto/lex-cli": "^0.9.9", 20 21 "@types/node": "^25.5.0", 21 22 "tsx": "^4.21.0", 22 23 "typescript": "^6.0.2", ··· 174 175 "tslib": "^2.8.1" 175 176 } 176 177 }, 178 + "node_modules/@atproto/lex-cli": { 179 + "version": "0.9.9", 180 + "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.9.tgz", 181 + "integrity": "sha512-n/ClCBNnrjbqflLFt+j8Vx8q+lQd6OPQiDToi80akUoW2wNyPmsWtK5tKje0HS+7aHZD3gO3yX4B/x6ZqqrEeA==", 182 + "dev": true, 183 + "license": "MIT", 184 + "dependencies": { 185 + "@atproto/lexicon": "^0.6.2", 186 + "@atproto/syntax": "^0.5.0", 187 + "chalk": "^4.1.2", 188 + "commander": "^9.4.0", 189 + "prettier": "^3.2.5", 190 + "ts-morph": "^24.0.0", 191 + "yesno": "^0.4.0", 192 + "zod": "^3.23.8" 193 + }, 194 + "bin": { 195 + "lex": "dist/index.js" 196 + }, 197 + "engines": { 198 + "node": ">=18.7.0" 199 + } 200 + }, 201 + "node_modules/@atproto/lex-cli/node_modules/@ts-morph/common": { 202 + "version": "0.25.0", 203 + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", 204 + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", 205 + "dev": true, 206 + "license": "MIT", 207 + "dependencies": { 208 + "minimatch": "^9.0.4", 209 + "path-browserify": "^1.0.1", 210 + "tinyglobby": "^0.2.9" 211 + } 212 + }, 213 + "node_modules/@atproto/lex-cli/node_modules/balanced-match": { 214 + "version": "1.0.2", 215 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 216 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 217 + "dev": true, 218 + "license": "MIT" 219 + }, 220 + "node_modules/@atproto/lex-cli/node_modules/brace-expansion": { 221 + "version": "2.0.3", 222 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", 223 + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", 224 + "dev": true, 225 + "license": "MIT", 226 + "dependencies": { 227 + "balanced-match": "^1.0.0" 228 + } 229 + }, 230 + "node_modules/@atproto/lex-cli/node_modules/minimatch": { 231 + "version": "9.0.9", 232 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", 233 + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", 234 + "dev": true, 235 + "license": "ISC", 236 + "dependencies": { 237 + "brace-expansion": "^2.0.2" 238 + }, 239 + "engines": { 240 + "node": ">=16 || 14 >=14.17" 241 + }, 242 + "funding": { 243 + "url": "https://github.com/sponsors/isaacs" 244 + } 245 + }, 246 + "node_modules/@atproto/lex-cli/node_modules/ts-morph": { 247 + "version": "24.0.0", 248 + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", 249 + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", 250 + "dev": true, 251 + "license": "MIT", 252 + "dependencies": { 253 + "@ts-morph/common": "~0.25.0", 254 + "code-block-writer": "^13.0.3" 255 + } 256 + }, 177 257 "node_modules/@atproto/lex-client": { 178 258 "version": "0.0.18", 179 259 "resolved": "https://registry.npmjs.org/@atproto/lex-client/-/lex-client-0.0.18.tgz", ··· 270 350 "@standard-schema/spec": "^1.1.0", 271 351 "iso-datestring-validator": "^2.2.2", 272 352 "tslib": "^2.8.1" 353 + } 354 + }, 355 + "node_modules/@atproto/lexicon": { 356 + "version": "0.6.2", 357 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.2.tgz", 358 + "integrity": "sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==", 359 + "dev": true, 360 + "license": "MIT", 361 + "dependencies": { 362 + "@atproto/common-web": "^0.4.18", 363 + "@atproto/syntax": "^0.5.0", 364 + "iso-datestring-validator": "^2.2.2", 365 + "multiformats": "^9.9.0", 366 + "zod": "^3.23.8" 273 367 } 274 368 }, 275 369 "node_modules/@atproto/repo": { ··· 2646 2740 "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 2647 2741 "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 2648 2742 "license": "MIT" 2743 + }, 2744 + "node_modules/commander": { 2745 + "version": "9.5.0", 2746 + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", 2747 + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", 2748 + "dev": true, 2749 + "license": "MIT", 2750 + "engines": { 2751 + "node": "^12.20.0 || >=14" 2752 + } 2649 2753 }, 2650 2754 "node_modules/content-disposition": { 2651 2755 "version": "1.0.1", ··· 5425 5529 "engines": { 5426 5530 "node": ">=12" 5427 5531 } 5532 + }, 5533 + "node_modules/yesno": { 5534 + "version": "0.4.0", 5535 + "resolved": "https://registry.npmjs.org/yesno/-/yesno-0.4.0.tgz", 5536 + "integrity": "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA==", 5537 + "dev": true, 5538 + "license": "BSD" 5428 5539 }, 5429 5540 "node_modules/zod": { 5430 5541 "version": "3.25.76",
+4 -1
package.json
··· 5 5 "type": "module", 6 6 "main": "src/bin/main.ts", 7 7 "scripts": { 8 - "test": "vitest run" 8 + "test": "vitest run", 9 + "generate": "tsx src/bin/generate", 10 + "server": "tsx src/bin/server" 9 11 }, 10 12 "keywords": [], 11 13 "author": "", 12 14 "license": "ISC", 13 15 "devDependencies": { 14 16 "@atproto/lex": "^0.0.23", 17 + "@atproto/lex-cli": "^0.9.9", 15 18 "@types/node": "^25.5.0", 16 19 "tsx": "^4.21.0", 17 20 "typescript": "^6.0.2",
-1
src/__tests__/schema-generated.expected.graphql
··· 56 56 type Lexicon_app_bsky_actor_defs_statusView { 57 57 cid: String 58 58 uri: String 59 - embed: Unknown 60 59 status: String! 61 60 isActive: Boolean 62 61 expiresAt: String
+1 -1
src/__tests__/schema-generation.test.ts
··· 26 26 const generatedDefinitions = generateDefinitions(lexiconIds); 27 27 28 28 // Join with double newlines like main.ts does 29 - const outputContent = generatedDefinitions.join("\n\n") + "\n"; 29 + const outputContent = generatedDefinitions.chunks.join("\n\n") + "\n"; 30 30 31 31 // The output should match the expected schema 32 32 expect(outputContent).toBe(expectedOutput);
+3 -7
src/bin/main.ts src/bin/generate.ts
··· 2 2 import { generateDefinitions } from "../generateLexiconSchema"; 3 3 import fs from "fs"; 4 4 5 + const lexiconIds = (await import("../../lexicons.json")).lexicons; 6 + 5 7 const args = process.argv.slice(2); 6 - let lexiconIds: string[] = []; 7 8 let output: string | null = null; 8 9 let appendSchema: string[] | null = null; 9 10 ··· 32 33 } 33 34 } 34 35 35 - if (lexiconIds.length === 0) { 36 - console.error("Error: No lexicon identifiers provided"); 37 - process.exit(1); 38 - } 39 - 40 36 let outputContent = ""; 41 37 42 38 // Append additional schemas if provided via --append-schema ··· 46 42 } 47 43 48 44 // Append the new schema. 49 - outputContent += generateDefinitions(lexiconIds).join("\n\n"); 45 + outputContent += generateDefinitions(lexiconIds).chunks.join("\n\n"); 50 46 51 47 // Write to output file 52 48 if (output) {
+81
src/bin/server.js
··· 1 + import express from "express"; 2 + import { createHandler } from "graphql-http/lib/use/http"; 3 + import { buildSchema } from "graphql"; 4 + import { ruruHTML } from "ruru/server"; 5 + import { serveStatic } from "ruru/static"; 6 + import { generateDefinitions } from "../generateLexiconSchema"; 7 + 8 + // Initialize Express app 9 + const app = express(); 10 + 11 + const lexiconIds = (await import("../../lexicons.json")).lexicons; 12 + 13 + // Generate definitions (this is what main.ts does when no --output flag is provided) 14 + const { chunks: generatedDefinitions, lexiconCalls } = 15 + generateDefinitions(lexiconIds); 16 + 17 + // Define GraphQL schema using GraphQL-JS 18 + const schema = buildSchema( 19 + generatedDefinitions.join("\n\n") + 20 + ` 21 + type Query { 22 + lexicon: Lexicon 23 + }`, 24 + ); 25 + 26 + const ty = (schema.getQueryType().getFields()["lexicon"].resolve = () => ({})); 27 + 28 + // Serve static files 29 + for (const key of Object.keys(lexiconCalls)) { 30 + const segments = key.split("."); 31 + const ty = schema.getType( 32 + ["Lexicon"].concat(segments.slice(0, -1)).join("_"), 33 + ); 34 + ty.getFields()[segments.slice(-1)[0]].resolve = lexiconCalls[key]; 35 + 36 + for (let i = 1; i < segments.length; i++) { 37 + const ty = schema.getType( 38 + ["Lexicon"].concat(segments.slice(0, i - 1)).join("_"), 39 + ); 40 + ty.getFields()[segments.slice(0, i).pop()].resolve = () => ({}); 41 + } 42 + } 43 + 44 + app.use(express.static("public")); 45 + 46 + const handler = createHandler({ schema }); 47 + 48 + // GraphQL endpoint 49 + app.all("/graphql", handler); 50 + 51 + /** 52 + * Setup GraphiQL 53 + */ 54 + 55 + const config = { staticPath: "/ruru-static/", endpoint: "/graphql" }; 56 + 57 + // Serve Ruru HTML 58 + app.get("/graphiql", (req, res) => { 59 + res.format({ 60 + html: () => res.status(200).send(ruruHTML(config)), 61 + default: () => res.status(406).send("Not Acceptable"), 62 + }); 63 + }); 64 + 65 + // Serve static files 66 + app.use(serveStatic(config.staticPath)); 67 + 68 + // Start the HTTP server 69 + const PORT = process.env.PORT || 4000; 70 + const httpServer = app.listen(PORT, () => { 71 + console.log(`Server ready at http://localhost:${PORT}`); 72 + console.log(`GraphQL endpoint: http://localhost:${PORT}/graphql`); 73 + console.log(`GraphiQL endpoint: http://localhost:${PORT}/graphiql`); 74 + }); 75 + 76 + // Handle server shutdown gracefully 77 + process.on("SIGINT", () => { 78 + httpServer.close(() => { 79 + console.log("Server closed"); 80 + }); 81 + });
+50 -18
src/generateLexiconSchema.ts
··· 1 1 #!/usr/bin/env ts-node 2 2 3 - import { jsonToLex } from "@atproto/lex"; 3 + import { jsonToLex, LexValue, xrpc } from "@atproto/lex"; 4 4 import fs from "fs"; 5 5 import path from "path"; 6 6 import { glob } from "glob"; ··· 50 50 "at-identifier": "ID", 51 51 }; 52 52 53 - const SKIP_FIELDS = new Set(["debug"]); // Fields to skip in GraphQL output 53 + const SKIP_FIELDS = new Set(["debug", "embed"]); // Fields to skip in GraphQL output 54 54 55 55 function resolveLexiconPath(lexiconPath: LexiconPath): string { 56 56 const dirParts = lexiconPath.segments; ··· 74 74 } 75 75 } 76 76 77 - function resolveReference(ref: string): any | null { 77 + function resolveReference(ref: string): LexValue | null { 78 78 try { 79 79 if (ref.includes("#")) { 80 80 const [lexiconId, typeName] = ref.split("#", 2); ··· 88 88 } 89 89 } 90 90 91 - function resolveReferenceType(lexiconId: string, typeName: string): any | null { 91 + function resolveReferenceType( 92 + lexiconId: string, 93 + typeName: string, 94 + ): LexValue | null { 92 95 try { 93 96 const lexiconPath = resolveLexiconPath( 94 97 LexiconPathImpl.fromString(lexiconId), ··· 240 243 export function generateLexiconStructure(lexiconPath: LexiconPath): { 241 244 methodDef: string; 242 245 objectDefinitions: Record<string, any>; 243 - } { 246 + methodCall: Function; 247 + } | null { 244 248 const methodName = lexiconPath.segments[lexiconPath.segments.length - 1]; 245 249 246 250 try { ··· 249 253 const parsed = jsonToLex(lexiconData); 250 254 251 255 if (!parsed || typeof parsed !== "object" || !("defs" in parsed)) { 252 - return { methodDef: "", objectDefinitions: {} }; 256 + return null; 253 257 } 254 258 255 259 const defs = parsed.defs; 256 260 const mainDef = defs.main; 257 261 258 262 if (!mainDef || mainDef.type !== "query") { 259 - return { methodDef: "", objectDefinitions: {} }; 263 + return null; 260 264 } 261 265 262 266 const referencedTypes = new Set<string>(); ··· 321 325 ? `${methodName}${methodParams}: ${returnType}` 322 326 : `${methodName}${methodParams}`; 323 327 324 - return { methodDef, objectDefinitions }; 328 + const methodCall = async function (_, params: any) { 329 + const result = await xrpc( 330 + "https://api.bsky.app", 331 + await import("./lexicons/" + lexiconPath.segments.join("/")), 332 + { 333 + params, 334 + }, 335 + ); 336 + if (!result.success) { 337 + throw new Error("Error in response: " + JSON.stringify(result.payload)); 338 + } 339 + return result.payload.body; 340 + }; 341 + 342 + return { methodDef, objectDefinitions, methodCall }; 325 343 } catch (e) { 326 344 console.error( 327 345 `Error processing lexicon ${lexiconPath.segments.join(".")}: ${e}`, 328 346 ); 329 - return { methodDef: "", objectDefinitions: {} }; 347 + return null; 330 348 } 331 349 } 332 350 333 - export function generateDefinitions(lexiconIds: string[]): string[] { 351 + export function generateDefinitions(lexiconIds: string[]): { 352 + chunks: string[]; 353 + lexiconCalls: Record<string, Function>; 354 + } { 334 355 const chunks: string[] = []; 335 356 const lexiconFields: Record<string, string> = {}; 336 - let objectDefinitions: Record<string, any> = {}; 357 + let lexiconCalls: Record<string, Function> = {}; 358 + let objectDefinitions: Record<string, LexValue> = {}; 337 359 338 360 for (const lexiconId of lexiconIds) { 339 361 const lexiconPath = LexiconPathImpl.fromString(lexiconId); 340 362 341 363 // Generate main lexicon structure 342 - const { methodDef, objectDefinitions: collectedObjectDefinitions } = 343 - generateLexiconStructure(lexiconPath); 344 - lexiconFields[lexiconPath.dotPath()] = methodDef; 345 - objectDefinitions = { ...objectDefinitions, ...collectedObjectDefinitions }; 364 + const result = generateLexiconStructure(lexiconPath); 365 + if (result) { 366 + const { 367 + methodDef, 368 + objectDefinitions: collectedObjectDefinitions, 369 + methodCall, 370 + } = result; 371 + lexiconFields[lexiconPath.dotPath()] = methodDef; 372 + lexiconCalls[lexiconPath.dotPath()] = methodCall; 373 + objectDefinitions = { 374 + ...objectDefinitions, 375 + ...collectedObjectDefinitions, 376 + }; 377 + } 346 378 } 347 379 348 380 // Walk output types for more refs 349 - const finalDefinitions: Record<string, any> = {}; 350 - let newDefinitions: Record<string, any> = {}; 381 + const finalDefinitions: Record<string, LexValue> = {}; 382 + let newDefinitions: Record<string, LexValue> = {}; 351 383 352 384 while (Object.keys(objectDefinitions).length > 0) { 353 385 for (const [objName, objDef] of Object.entries(objectDefinitions)) { ··· 404 436 ...outputLexiconNamespaces(lexiconFields, new LexiconPathImpl([])), 405 437 ); 406 438 407 - return chunks; 439 + return { chunks, lexiconCalls }; 408 440 } 409 441 410 442 export async function readSchemaFiles(
-78
src/server/server.js
··· 1 - import express from "express"; 2 - import { createHandler } from 'graphql-http/lib/use/http'; 3 - import { buildSchema } from "graphql"; 4 - import { ruruHTML } from "ruru/server"; 5 - import { serveStatic } from "ruru/static"; 6 - 7 - // Initialize Express app 8 - const app = express(); 9 - 10 - // Define GraphQL schema using GraphQL-JS 11 - const schema = buildSchema(` 12 - type Query { 13 - hello: String 14 - user(id: ID!): User 15 - } 16 - 17 - type User { 18 - id: ID! 19 - name: String! 20 - email: String! 21 - } 22 - `); 23 - 24 - // Define GraphQL resolvers 25 - const root = { 26 - hello: () => "Hello, World!", 27 - user: ({ id }) => { 28 - // Mock data 29 - const users = { 30 - 1: { id: "1", name: "Alice", email: "alice@example.com" }, 31 - 2: { id: "2", name: "Bob", email: "bob@example.com" }, 32 - }; 33 - return users[id]; 34 - }, 35 - }; 36 - 37 - // Serve static files 38 - app.use(express.static("public")); 39 - 40 - const handler = createHandler({ schema }); 41 - 42 - // GraphQL endpoint 43 - app.all( 44 - "/graphql", 45 - createHandler({ schema }) 46 - ); 47 - 48 - /** 49 - * Setup GraphiQL 50 - */ 51 - 52 - const config = { staticPath: "/ruru-static/", endpoint: "/graphql" }; 53 - 54 - // Serve Ruru HTML 55 - app.get("/graphiql", (req, res) => { 56 - res.format({ 57 - html: () => res.status(200).send(ruruHTML(config)), 58 - default: () => res.status(406).send("Not Acceptable"), 59 - }); 60 - }); 61 - 62 - // Serve static files 63 - app.use(serveStatic(config.staticPath)); 64 - 65 - // Start the HTTP server 66 - const PORT = process.env.PORT || 4000; 67 - const httpServer = app.listen(PORT, () => { 68 - console.log(`Server ready at http://localhost:${PORT}`); 69 - console.log(`GraphQL endpoint: http://localhost:${PORT}/graphql`); 70 - console.log(`GraphiQL endpoint: http://localhost:${PORT}/graphiql`); 71 - }); 72 - 73 - // Handle server shutdown gracefully 74 - process.on("SIGINT", () => { 75 - httpServer.close(() => { 76 - console.log("Server closed"); 77 - }); 78 - });