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 165/** src/routes.ts **/ 166// Homepage 167router.get( 168 '/', 169 handler(async (req, res) => { 170 // If the user is signed in, get an agent which communicates with their server 171 const agent = await getSessionAgent(req, res, ctx) 172 173 if (!agent) { 174 // Serve the logged-out view 175 return res.type('html').send(page(home())) 176 } 177 178 // Fetch additional information about the logged-in user 179 const { data: profileRecord } = await agent.getRecord({ 180 repo: agent.accountDid, // our user's repo 181 collection: 'app.bsky.actor.profile', // the bluesky profile record type 182 rkey: 'self', // the record's name 183 }) 184 185 // Serve the logged-in view 186 return res 187 .type('html') 188 .send(page(home({ profile: profileRecord.value || {} }))) 189 }) 190) 191``` 192 193With that data, we can give a nice personalized welcome banner for our user: 194 195```html 196<!-- pages/home.ts --> 197<div class="card"> 198 ${profile 199 ? html`<form action="/logout" method="post" class="session-form"> 200 <div> 201 Hi, <strong>${profile.displayName || 'friend'}</strong>. 202 What's your status today? 203 </div> 204 <div> 205 <button type="submit">Log out</button> 206 </div> 207 </form>` 208 : html`<div class="session-form"> 209 <div><a href="/login">Log in</a> to set your status!</div> 210 <div> 211 <a href="/login" class="button">Log in</a> 212 </div> 213 </div>`} 214</div> 215``` 216 217## Step 4. Reading & writing records 218 219You can think of the user repositories as collections of JSON records: 220 221``` 222 ┌────────┐ 223 ┌───| record │ 224 ┌────────────┐ │ └────────┘ 225 ┌───| collection |◄─┤ ┌────────┐ 226┌──────┐ │ └────────────┘ └───| record │ 227│ repo |◄──┤ └────────┘ 228└──────┘ │ ┌────────────┐ ┌────────┐ 229 └───┤ collection |◄─────| record │ 230 └────────────┘ └────────┘ 231``` 232 233Let's look again at how we read the "profile" record: 234 235```typescript 236await agent.getRecord({ 237 repo: agent.accountDid, // The user 238 collection: 'app.bsky.actor.profile', // The collection 239 rkey: 'self', // The record key 240}) 241``` 242 243We write records using a similar API. Since our goal is to write "status" records, let's look at how that will happen: 244 245```typescript 246// Generate a time-based key for our record 247const rkey = TID.nextStr() 248 249// Write the 250await agent.putRecord({ 251 repo: agent.accountDid, // The user 252 collection: 'com.example.status', // The collection 253 rkey, // The record key 254 record: { // The record value 255 status: "👍", 256 createdAt: new Date().toISOString() 257 } 258}) 259``` 260 261Our `POST /status` route is going to use this API to publish the user's status to their repo. 262 263```typescript 264/** src/routes.ts **/ 265// "Set status" handler 266router.post( 267 '/status', 268 handler(async (req, res) => { 269 // If the user is signed in, get an agent which communicates with their server 270 const agent = await getSessionAgent(req, res, ctx) 271 if (!agent) { 272 return res.status(401).json({ error: 'Session required' }) 273 } 274 275 // Construct their status record 276 const record = { 277 $type: 'com.example.status', 278 status: req.body?.status, 279 createdAt: new Date().toISOString(), 280 } 281 282 try { 283 // Write the status record to the user's repository 284 await agent.putRecord({ 285 repo: agent.accountDid, 286 collection: 'com.example.status', 287 rkey: TID.nextStr(), 288 record, 289 }) 290 } catch (err) { 291 ctx.logger.warn({ err }, 'failed to write record') 292 return res.status(500).json({ error: 'Failed to write record' }) 293 } 294 295 res.status(200).json({}) 296 }) 297) 298``` 299 300Now in our homepage we can list out the status buttons: 301 302```html 303<!-- src/pages/home.ts --> 304<div class="status-options"> 305 ${['👍', '🦋', '🥳', /*...*/].map(status => html` 306 <div class="status-option" data-value="${status}"> 307 ${status} 308 </div>` 309 )} 310</div> 311``` 312 313And write some client-side javascript to submit the status on click: 314 315```javascript 316/* src/pages/public/home.js */ 317Array.from(document.querySelectorAll('.status-option'), (el) => { 318 el.addEventListener('click', async (ev) => { 319 const res = await fetch('/status', { 320 method: 'POST', 321 headers: { 'content-type': 'application/json' }, 322 body: JSON.stringify({ status: el.dataset.value }), 323 }) 324 const body = await res.json() 325 if (!body?.error) { 326 location.reload() 327 } 328 }) 329}) 330``` 331 332## Step 5. Creating a custom "status" schema 333 334The 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). 335 336Anybody 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. 337 338> ### Why create a schema? 339> 340> 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. 341 342Let's create our schema in the `/lexicons` folder of our codebase. You can [read more about how to define schemas here](#todo). 343 344```json 345/* lexicons/status.json */ 346{ 347 "lexicon": 1, 348 "id": "com.example.status", 349 "defs": { 350 "main": { 351 "type": "record", 352 "key": "tid", 353 "record": { 354 "type": "object", 355 "required": ["status", "createdAt"], 356 "properties": { 357 "status": { 358 "type": "string", 359 "minLength": 1, 360 "maxGraphemes": 1, 361 "maxLength": 32 362 }, 363 "createdAt": { 364 "type": "string", 365 "format": "datetime" 366 } 367 } 368 } 369 } 370 } 371} 372``` 373 374Now let's run some code-generation using our schema: 375 376```bash 377./node_modules/.bin/lex gen-server ./src/lexicon ./lexicons/* 378``` 379 380This will produce Typescript interfaces as well as runtime validation functions that we can use in our `POST /status` route: 381 382```typescript 383/** src/routes.ts **/ 384import * as Status from '#/lexicon/types/com/example/status' 385// ... 386// "Set status" handler 387router.post( 388 '/status', 389 handler(async (req, res) => { 390 // ... 391 392 // Construct & validate their status record 393 const record = { 394 $type: 'com.example.status', 395 status: req.body?.status, 396 createdAt: new Date().toISOString(), 397 } 398 if (!Status.validateRecord(record).success) { 399 return res.status(400).json({ error: 'Invalid status' }) 400 } 401 402 // ... 403 }) 404) 405``` 406 407## Step 6. Listening to the firehose 408 409So far, we have: 410 411- Logged in via OAuth 412- Created a custom schema 413- Read & written records for the logged in user 414 415Now we want to fetch the status records from other users. 416 417Remember 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. 418 419``` 420┌──────┐ 421│ REPO │ Event stream 422├──────┘ 423│ ┌───────────────────────────────────────────┐ 424├───┼ 1 PUT /app.bsky.feed.post/3l244rmrxjx2v │ 425│ └───────────────────────────────────────────┘ 426│ ┌───────────────────────────────────────────┐ 427├───┼ 2 DEL /app.bsky.feed.post/3l244rmrxjx2v │ 428│ └───────────────────────────────────────────┘ 429│ ┌───────────────────────────────────────────┐ 430├───┼ 3 PUT /app.bsky.actor.profile/self │ 431▼ └───────────────────────────────────────────┘ 432``` 433 434Using 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. 435 436 437```typescript 438/** src/firehose.ts **/ 439import * as Status from '#/lexicon/types/com/example/status' 440// ... 441const firehose = new Firehose({}) 442 443for await (const evt of firehose.run()) { 444 // Watch for write events 445 if (evt.event === 'create' || evt.event === 'update') { 446 const record = evt.record 447 448 // If the write is a valid status update 449 if ( 450 evt.collection === 'com.example.status' && 451 Status.isRecord(record) && 452 Status.validateRecord(record).success 453 ) { 454 // Store the status 455 // TODO 456 } 457 } 458} 459``` 460 461Let's create a SQLite table to store these statuses: 462 463```typescript 464/** src/db.ts **/ 465// Create our statuses table 466await db.schema 467 .createTable('status') 468 .addColumn('uri', 'varchar', (col) => col.primaryKey()) 469 .addColumn('authorDid', 'varchar', (col) => col.notNull()) 470 .addColumn('status', 'varchar', (col) => col.notNull()) 471 .addColumn('createdAt', 'varchar', (col) => col.notNull()) 472 .addColumn('indexedAt', 'varchar', (col) => col.notNull()) 473 .execute() 474``` 475 476Now we can write these statuses into our database as they arrive from the firehose: 477 478```typescript 479/** src/firehose.ts **/ 480// If the write is a valid status update 481if ( 482 evt.collection === 'com.example.status' && 483 Status.isRecord(record) && 484 Status.validateRecord(record).success 485) { 486 // Store the status in our SQLite 487 await db 488 .insertInto('status') 489 .values({ 490 uri: evt.uri.toString(), 491 authorDid: evt.author, 492 status: record.status, 493 createdAt: record.createdAt, 494 indexedAt: new Date().toISOString(), 495 }) 496 .onConflict((oc) => 497 oc.column('uri').doUpdateSet({ 498 status: record.status, 499 indexedAt: new Date().toISOString(), 500 }) 501 ) 502 .execute() 503} 504``` 505 506You can almost think of information flowing in a loop: 507 508``` 509 ┌─────Repo put─────┐ 510 │ ▼ 511┌──────┴─────┐ ┌───────────┐ 512│ App server │ │ User repo │ 513└────────────┘ └─────┬─────┘ 514 ▲ │ 515 └────Event log─────┘ 516``` 517 518Why 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. 519 520## Step 7. Listing the latest statuses 521 522Now 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: 523 524```typescript 525/** src/routes.ts **/ 526// Homepage 527router.get( 528 '/', 529 handler(async (req, res) => { 530 // ... 531 532 // Fetch data stored in our SQLite 533 const statuses = await db 534 .selectFrom('status') 535 .selectAll() 536 .orderBy('indexedAt', 'desc') 537 .limit(10) 538 .execute() 539 540 // Map user DIDs to their domain-name handles 541 const didHandleMap = await resolver.resolveDidsToHandles( 542 statuses.map((s) => s.authorDid) 543 ) 544 545 // ... 546 }) 547) 548``` 549 550Our HTML can now list these status records: 551 552```html 553<!-- src/pages/home.ts --> 554${statuses.map((status, i) => { 555 const handle = didHandleMap[status.authorDid] || status.authorDid 556 return html` 557 <div class="status-line"> 558 <div> 559 <div class="status">${status.status}</div> 560 </div> 561 <div class="desc"> 562 <a class="author" href="https://bsky.app/profile/${handle}">@${handle}</a> 563 was feeling ${status.status} on ${status.indexedAt}. 564 </div> 565 </div> 566 ` 567})} 568``` 569 570## Step 8. Optimistic updates 571 572As 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: 573 574``` 575 ┌───Repo put──┬──────┐ 576 │ │ ▼ 577┌──────┴─────┐ │ ┌───────────┐ 578│ App server │◄──────┘ │ User repo │ 579└────────────┘ └───┬───────┘ 580 ▲ │ 581 └────Event log───────┘ 582``` 583 584This 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. 585 586To do this, we just update `POST /status` to include an additional write to our SQLite DB: 587 588```typescript 589/** src/routes.ts **/ 590// "Set status" handler 591router.post( 592 '/status', 593 handler(async (req, res) => { 594 // ... 595 596 let uri 597 try { 598 // Write the status record to the user's repository 599 const res = await agent.putRecord({ 600 repo: agent.accountDid, 601 collection: 'com.example.status', 602 rkey: TID.nextStr(), 603 record, 604 }) 605 uri = res.uri 606 } catch (err) { 607 ctx.logger.warn({ err }, 'failed to write record') 608 return res.status(500).json({ error: 'Failed to write record' }) 609 } 610 611 try { 612 // Optimistically update our SQLite <-- HERE! 613 await ctx.db 614 .insertInto('status') 615 .values({ 616 uri, 617 authorDid: agent.accountDid, 618 status: record.status, 619 createdAt: record.createdAt, 620 indexedAt: new Date().toISOString(), 621 }) 622 .execute() 623 } catch (err) { 624 ctx.logger.warn( 625 { err }, 626 'failed to update computed view; ignoring as it should be caught by the firehose' 627 ) 628 } 629 630 res.status(200).json({}) 631 }) 632) 633``` 634 635You'll notice this code looks almost exactly like what we're doing in `firehose.ts`. 636 637## Next steps 638 639TODO 640 641