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