Scratch space for learning atproto app development
1# Tutorial 2 3In this guide, we're going to build a **simple multi-user app** that publishes your current "status" as an emoji. 4 5At various points we will cover how to: 6 7- Signin via OAuth 8- Fetch information about users (profiles) 9- Listen to the network firehose for new data 10- Publish data on the user's account using a custom schema 11 12We're going to keep this light so you can quickly wrap your head around ATProto. There will be links with more information about each step. 13 14## Where are we going? 15 16Data in the Atmosphere is stored on users' personal repos. It's almost like each user has their own website. Our goal is to aggregate data from the users into our SQLite DB. 17 18Think of our app like a Google. If Google's job was to say which emoji each website had under `/status.json`, then it would show something like: 19 20- `nytimes.com` is feeling 📰 according to `https://nytimes.com/status.json` 21- `bsky.app` is feeling 🦋 according to `https://bsky.app/status.json` 22- `reddit.com` is feeling 🤓 according to `https://reddit.com/status.json` 23 24The Atmosphere works the same way, except we're going to check `at://` instead of `https://`. Each user has a data repo under an `at://` URL. We'll crawl all the `at://`s in the Atmosphere for all the "status.json" records and aggregate them into our SQLite database. 25 26> `at://` is the URL scheme of the AT Protocol. 27 28## Step 1. Starting with our ExpressJS app 29 30Start by cloning the repo and installing packages. 31 32```bash 33git clone TODO 34cd TODO 35npm i 36npm run dev # you can leave this running and it will auto-reload 37``` 38 39Our repo is a regular Web app. We're rendering our HTML server-side like it's 1999. We also have a SQLite database that we're managing with [Kysley](#todo). 40 41Our starting stack: 42 43- Typescript 44- NodeJS web server ([express](#todo)) 45- SQLite database ([Kysley](#todo)) 46- Server-side rendering ([uhtml](#todo)) 47 48With each step we'll explain how our Web app taps into the Atmosphere. Refer to the codebase for more detailed code &mdash; again, this tutorial is going to keep it light and quick to digest. 49 50## Step 2. Signing in with OAuth 51 52When somebody logs into our app, they'll give us read & write access to their personal `at://` repo. We'll use that to write the `status.json` record. 53 54We're going to accomplish this using OAuth ([spec](#todo)). You can find a [more extensive OAuth guide here](#todo), but for now just know that most of the OAuth flows are going to be handled for us using the [@atproto/oauth-client-node](#todo) library. This is the arrangement we're aiming toward: 55 56``` 57 ┌─App Server───────────────────┐ 58 │ ┌─► Session store ◄┐ │ 59 │ │ │ │ ┌───────────────┐ 60 │ App code ──────►OAuth client─┼───►│ User's server │ 61 └────▲─────────────────────────┘ └───────────────┘ 62 ┌────┴──────────┐ 63 │ Web browser │ 64 └───────────────┘ 65``` 66 67When the user logs in, the OAuth client will create a new session with their repo server and give us read/write access along with basic user info. 68 69Our login page just asks the user for their "handle," which is the domain name associated with their account. For [Bluesky](https://bsky.app) users, these tend to look like `alice.bsky.social`, but they can be any kind of domain (eg `alice.com`). 70 71```html 72<!-- src/pages/login.ts --> 73<form action="/login" method="post" class="login-form"> 74 <input 75 type="text" 76 name="handle" 77 placeholder="Enter your handle (eg alice.bsky.social)" 78 required 79 /> 80 <button type="submit">Log in</button> 81</form> 82``` 83 84When they submit the form, we tell our OAuth client to initiate the authorization flow and then redirect the user to their server to complete the process. 85 86```typescript 87/** src/routes.ts **/ 88// Login handler 89router.post( 90 '/login', 91 handler(async (req, res) => { 92 // Initiate the OAuth flow 93 const url = await oauthClient.authorize(handle) 94 return res.redirect(url.toString()) 95 }) 96) 97``` 98 99This is the same kind of SSO flow that Google or GitHub uses. The user will be asked for their password, then asked to confirm the session with your application. 100 101When that finishes, they'll be sent back to `/oauth/callback` on our Web app. The OAuth client stores the access tokens for the server, and then we attach their account's [DID](#todo) to their cookie-session. 102 103```typescript 104/** src/routes.ts **/ 105// OAuth callback to complete session creation 106router.get( 107 '/oauth/callback', 108 handler(async (req, res) => { 109 // Store the credentials 110 const { agent } = await oauthClient.callback(params) 111 112 // Attach the account DID to our user via a cookie 113 const session = await getIronSession(req, res) 114 session.did = agent.accountDid 115 await session.save() 116 117 // Send them back to the app 118 return res.redirect('/') 119 }) 120) 121``` 122 123With that, we're in business! We now have a session with the user's `at://` repo server and can use that to access their data. 124 125## Step 3. Fetching the user's profile 126 127Why don't we learn something about our user? Let's start by getting the [Agent](#todo) object. The [Agent](#todo) is the client to the user's `at://` repo server. 128 129```typescript 130/** src/routes.ts **/ 131async function getSessionAgent( 132 req: IncomingMessage, 133 res: ServerResponse<IncomingMessage>, 134 ctx: AppContext 135) { 136 // Fetch the session from their cookie 137 const session = await getIronSession(req, res) 138 if (!session.did) return null 139 140 // "Restore" the agent for the user 141 return await ctx.oauthClient.restore(session.did).catch(async (err) => { 142 ctx.logger.warn({ err }, 'oauth restore failed') 143 await session.destroy() 144 return null 145 }) 146} 147``` 148 149Users publish JSON records on their `at://` repos. In [Bluesky](https://bsky.app), they publish a "profile" record which looks like this: 150 151```typescript 152interface ProfileRecord { 153 displayName?: string // a human friendly name 154 description?: string // a short bio 155 avatar?: BlobRef // small profile picture 156 banner?: BlobRef // banner image to put on profiles 157 createdAt?: string // declared time this profile data was added 158 // ... 159} 160``` 161 162We're going to use the [Agent](#todo) to fetch this record to include in our app. 163 164```typescript 165await agent.getRecord({ 166 repo: agent.accountDid, // The user 167 collection: 'app.bsky.actor.profile', // The collection 168 rkey: 'self', // The record key 169}) 170``` 171 172When asking for a record, we provide three pieces of information. 173 174- The [DID](#todo) which identifies the user, 175- The collection name, and 176- The record key 177 178We'll explain the collection name shortly. Record keys are strings with [some limitations](https://atproto.com/specs/record-key#record-key-syntax) and a couple of common patterns. The `"self"` pattern is used when a collection is expected to only contain one record which describes the user. 179 180Let's update our homepage to fetch this profile record: 181 182```typescript 183/** src/routes.ts **/ 184// Homepage 185router.get( 186 '/', 187 handler(async (req, res) => { 188 // If the user is signed in, get an agent which communicates with their server 189 const agent = await getSessionAgent(req, res, ctx) 190 191 if (!agent) { 192 // Serve the logged-out view 193 return res.type('html').send(page(home())) 194 } 195 196 // Fetch additional information about the logged-in user 197 const { data: profileRecord } = await agent.getRecord({ 198 repo: agent.accountDid, // our user's repo 199 collection: 'app.bsky.actor.profile', // the bluesky profile record type 200 rkey: 'self', // the record's name 201 }) 202 203 // Serve the logged-in view 204 return res 205 .type('html') 206 .send(page(home({ profile: profileRecord.value || {} }))) 207 }) 208) 209``` 210 211With that data, we can give a nice personalized welcome banner for our user: 212 213```html 214<!-- pages/home.ts --> 215<div class="card"> 216 ${profile 217 ? html`<form action="/logout" method="post" class="session-form"> 218 <div> 219 Hi, <strong>${profile.displayName || 'friend'}</strong>. 220 What's your status today? 221 </div> 222 <div> 223 <button type="submit">Log out</button> 224 </div> 225 </form>` 226 : html`<div class="session-form"> 227 <div><a href="/login">Log in</a> to set your status!</div> 228 <div> 229 <a href="/login" class="button">Log in</a> 230 </div> 231 </div>`} 232</div> 233``` 234 235You can examine this record directly using [atproto-browser.vercel.app](https://atproto-browser.vercel.app). For instance, [this is the profile record for @bsky.app](https://atproto-browser.vercel.app/at?u=at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.actor.profile/self). 236 237## Step 4. Reading & writing records 238 239You can think of the user repositories as collections of JSON records: 240 241``` 242 ┌────────┐ 243 ┌───| record │ 244 ┌────────────┐ │ └────────┘ 245 ┌───| collection |◄─┤ ┌────────┐ 246┌──────┐ │ └────────────┘ └───| record │ 247│ repo |◄──┤ └────────┘ 248└──────┘ │ ┌────────────┐ ┌────────┐ 249 └───┤ collection |◄─────| record │ 250 └────────────┘ └────────┘ 251``` 252 253Let's look again at how we read the "profile" record: 254 255```typescript 256await agent.getRecord({ 257 repo: agent.accountDid, // The user 258 collection: 'app.bsky.actor.profile', // The collection 259 rkey: 'self', // The record key 260}) 261``` 262 263We write records using a similar API. Since our goal is to write "status" records, let's look at how that will happen: 264 265```typescript 266// Generate a time-based key for our record 267const rkey = TID.nextStr() 268 269// Write the 270await agent.putRecord({ 271 repo: agent.accountDid, // The user 272 collection: 'com.example.status', // The collection 273 rkey, // The record key 274 record: { // The record value 275 status: "👍", 276 createdAt: new Date().toISOString() 277 } 278}) 279``` 280 281Our `POST /status` route is going to use this API to publish the user's status to their repo. 282 283```typescript 284/** src/routes.ts **/ 285// "Set status" handler 286router.post( 287 '/status', 288 handler(async (req, res) => { 289 // If the user is signed in, get an agent which communicates with their server 290 const agent = await getSessionAgent(req, res, ctx) 291 if (!agent) { 292 return res.status(401).json({ error: 'Session required' }) 293 } 294 295 // Construct their status record 296 const record = { 297 $type: 'com.example.status', 298 status: req.body?.status, 299 createdAt: new Date().toISOString(), 300 } 301 302 try { 303 // Write the status record to the user's repository 304 await agent.putRecord({ 305 repo: agent.accountDid, 306 collection: 'com.example.status', 307 rkey: TID.nextStr(), 308 record, 309 }) 310 } catch (err) { 311 ctx.logger.warn({ err }, 'failed to write record') 312 return res.status(500).json({ error: 'Failed to write record' }) 313 } 314 315 res.status(200).json({}) 316 }) 317) 318``` 319 320Now in our homepage we can list out the status buttons: 321 322```html 323<!-- src/pages/home.ts --> 324<div class="status-options"> 325 ${['👍', '🦋', '🥳', /*...*/].map(status => html` 326 <div class="status-option" data-value="${status}"> 327 ${status} 328 </div>` 329 )} 330</div> 331``` 332 333And write some client-side javascript to submit the status on click: 334 335```javascript 336/* src/pages/public/home.js */ 337Array.from(document.querySelectorAll('.status-option'), (el) => { 338 el.addEventListener('click', async (ev) => { 339 const res = await fetch('/status', { 340 method: 'POST', 341 headers: { 'content-type': 'application/json' }, 342 body: JSON.stringify({ status: el.dataset.value }), 343 }) 344 const body = await res.json() 345 if (!body?.error) { 346 location.reload() 347 } 348 }) 349}) 350``` 351 352## Step 5. Creating a custom "status" schema 353 354The collections are typed, meaning that they have a defined schema. The `app.bsky.actor.profile` type definition [can be found here](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/actor/profile.json). 355 356Anybody can create a new schema using the [Lexicon](#todo) language, which is very similar to [JSON-Schema](#todo). The schemas use [reverse-DNS IDs](#todo) which indicate ownership, but for this demo app we're going to use `com.example` which is safe for non-production software. 357 358> ### Why create a schema? 359> 360> Schemas help other applications understand the data your app is creating. By publishing your schemas, you enable compatibility with other apps and reduce the chances of bad data affecting your app. 361 362Let's create our schema in the `/lexicons` folder of our codebase. You can [read more about how to define schemas here](#todo). 363 364```json 365/* lexicons/status.json */ 366{ 367 "lexicon": 1, 368 "id": "com.example.status", 369 "defs": { 370 "main": { 371 "type": "record", 372 "key": "tid", 373 "record": { 374 "type": "object", 375 "required": ["status", "createdAt"], 376 "properties": { 377 "status": { 378 "type": "string", 379 "minLength": 1, 380 "maxGraphemes": 1, 381 "maxLength": 32 382 }, 383 "createdAt": { 384 "type": "string", 385 "format": "datetime" 386 } 387 } 388 } 389 } 390 } 391} 392``` 393 394Now let's run some code-generation using our schema: 395 396```bash 397./node_modules/.bin/lex gen-server ./src/lexicon ./lexicons/* 398``` 399 400This will produce Typescript interfaces as well as runtime validation functions that we can use in our `POST /status` route: 401 402```typescript 403/** src/routes.ts **/ 404import * as Status from '#/lexicon/types/com/example/status' 405// ... 406// "Set status" handler 407router.post( 408 '/status', 409 handler(async (req, res) => { 410 // ... 411 412 // Construct & validate their status record 413 const record = { 414 $type: 'com.example.status', 415 status: req.body?.status, 416 createdAt: new Date().toISOString(), 417 } 418 if (!Status.validateRecord(record).success) { 419 return res.status(400).json({ error: 'Invalid status' }) 420 } 421 422 // ... 423 }) 424) 425``` 426 427## Step 6. Listening to the firehose 428 429So far, we have: 430 431- Logged in via OAuth 432- Created a custom schema 433- Read & written records for the logged in user 434 435Now we want to fetch the status records from other users. 436 437Remember how we referred to our app as being like a Google, crawling around the repos to get their records? One advantage we have in the AT Protocol is that each repo publishes an event log of their updates. 438 439``` 440┌──────┐ 441│ REPO │ Event stream 442├──────┘ 443│ ┌───────────────────────────────────────────┐ 444├───┼ 1 PUT /app.bsky.feed.post/3l244rmrxjx2v │ 445│ └───────────────────────────────────────────┘ 446│ ┌───────────────────────────────────────────┐ 447├───┼ 2 DEL /app.bsky.feed.post/3l244rmrxjx2v │ 448│ └───────────────────────────────────────────┘ 449│ ┌───────────────────────────────────────────┐ 450├───┼ 3 PUT /app.bsky.actor.profile/self │ 451▼ └───────────────────────────────────────────┘ 452``` 453 454Using a [Relay service](#todo) we can listen to an aggregated firehose of these events across all users in the network. In our case what we're looking for are valid `com.example.status` records. 455 456 457```typescript 458/** src/firehose.ts **/ 459import * as Status from '#/lexicon/types/com/example/status' 460// ... 461const firehose = new Firehose({}) 462 463for await (const evt of firehose.run()) { 464 // Watch for write events 465 if (evt.event === 'create' || evt.event === 'update') { 466 const record = evt.record 467 468 // If the write is a valid status update 469 if ( 470 evt.collection === 'com.example.status' && 471 Status.isRecord(record) && 472 Status.validateRecord(record).success 473 ) { 474 // Store the status 475 // TODO 476 } 477 } 478} 479``` 480 481Let's create a SQLite table to store these statuses: 482 483```typescript 484/** src/db.ts **/ 485// Create our statuses table 486await db.schema 487 .createTable('status') 488 .addColumn('uri', 'varchar', (col) => col.primaryKey()) 489 .addColumn('authorDid', 'varchar', (col) => col.notNull()) 490 .addColumn('status', 'varchar', (col) => col.notNull()) 491 .addColumn('createdAt', 'varchar', (col) => col.notNull()) 492 .addColumn('indexedAt', 'varchar', (col) => col.notNull()) 493 .execute() 494``` 495 496Now we can write these statuses into our database as they arrive from the firehose: 497 498```typescript 499/** src/firehose.ts **/ 500// If the write is a valid status update 501if ( 502 evt.collection === 'com.example.status' && 503 Status.isRecord(record) && 504 Status.validateRecord(record).success 505) { 506 // Store the status in our SQLite 507 await db 508 .insertInto('status') 509 .values({ 510 uri: evt.uri.toString(), 511 authorDid: evt.author, 512 status: record.status, 513 createdAt: record.createdAt, 514 indexedAt: new Date().toISOString(), 515 }) 516 .onConflict((oc) => 517 oc.column('uri').doUpdateSet({ 518 status: record.status, 519 indexedAt: new Date().toISOString(), 520 }) 521 ) 522 .execute() 523} 524``` 525 526You can almost think of information flowing in a loop: 527 528``` 529 ┌─────Repo put─────┐ 530 │ ▼ 531┌──────┴─────┐ ┌───────────┐ 532│ App server │ │ User repo │ 533└────────────┘ └─────┬─────┘ 534 ▲ │ 535 └────Event log─────┘ 536``` 537 538Why read from the event log? Because there are other apps in the network that will write the records we're interested in. By subscribing to the event log, we ensure that we catch all the data we're interested in -- including data published by other apps. 539 540## Step 7. Listing the latest statuses 541 542Now that we have statuses populating our SQLite, we can produce a timeline of status updates by users. We also use a [DID](#todo)-to-handle resolver so we can show a nice username with the statuses: 543 544```typescript 545/** src/routes.ts **/ 546// Homepage 547router.get( 548 '/', 549 handler(async (req, res) => { 550 // ... 551 552 // Fetch data stored in our SQLite 553 const statuses = await db 554 .selectFrom('status') 555 .selectAll() 556 .orderBy('indexedAt', 'desc') 557 .limit(10) 558 .execute() 559 560 // Map user DIDs to their domain-name handles 561 const didHandleMap = await resolver.resolveDidsToHandles( 562 statuses.map((s) => s.authorDid) 563 ) 564 565 // ... 566 }) 567) 568``` 569 570Our HTML can now list these status records: 571 572```html 573<!-- src/pages/home.ts --> 574${statuses.map((status, i) => { 575 const handle = didHandleMap[status.authorDid] || status.authorDid 576 return html` 577 <div class="status-line"> 578 <div> 579 <div class="status">${status.status}</div> 580 </div> 581 <div class="desc"> 582 <a class="author" href="https://bsky.app/profile/${handle}">@${handle}</a> 583 was feeling ${status.status} on ${status.indexedAt}. 584 </div> 585 </div> 586 ` 587})} 588``` 589 590## Step 8. Optimistic updates 591 592As a final optimization, let's introduce "optimistic updates." Remember the information flow loop with the repo write and the event log? Since we're updating our users' repos locally, we can short-circuit that flow to our own database: 593 594``` 595 ┌───Repo put──┬──────┐ 596 │ │ ▼ 597┌──────┴─────┐ │ ┌───────────┐ 598│ App server │◄──────┘ │ User repo │ 599└────────────┘ └───┬───────┘ 600 ▲ │ 601 └────Event log───────┘ 602``` 603 604This is an important optimization to make, because it ensures that the user sees their own changes while using your app. When the event eventually arrives from the firehose, we just discard it since we already have it saved locally. 605 606To do this, we just update `POST /status` to include an additional write to our SQLite DB: 607 608```typescript 609/** src/routes.ts **/ 610// "Set status" handler 611router.post( 612 '/status', 613 handler(async (req, res) => { 614 // ... 615 616 let uri 617 try { 618 // Write the status record to the user's repository 619 const res = await agent.putRecord({ 620 repo: agent.accountDid, 621 collection: 'com.example.status', 622 rkey: TID.nextStr(), 623 record, 624 }) 625 uri = res.uri 626 } catch (err) { 627 ctx.logger.warn({ err }, 'failed to write record') 628 return res.status(500).json({ error: 'Failed to write record' }) 629 } 630 631 try { 632 // Optimistically update our SQLite <-- HERE! 633 await ctx.db 634 .insertInto('status') 635 .values({ 636 uri, 637 authorDid: agent.accountDid, 638 status: record.status, 639 createdAt: record.createdAt, 640 indexedAt: new Date().toISOString(), 641 }) 642 .execute() 643 } catch (err) { 644 ctx.logger.warn( 645 { err }, 646 'failed to update computed view; ignoring as it should be caught by the firehose' 647 ) 648 } 649 650 res.status(200).json({}) 651 }) 652) 653``` 654 655You'll notice this code looks almost exactly like what we're doing in `firehose.ts`. 656 657## Next steps 658 659TODO 660 661