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
5
6
7At various points we will cover how to:
8
9- Signin via OAuth
10- Fetch information about users (profiles)
11- Listen to the network firehose for new data
12- Publish data on the user's account using a custom schema
13
14We'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.
15
16## Where are we going?
17
18Data 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.
19
20Think 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:
21
22- `nytimes.com` is feeling 📰 according to `https://nytimes.com/status.json`
23- `bsky.app` is feeling 🦋 according to `https://bsky.app/status.json`
24- `reddit.com` is feeling 🤓 according to `https://reddit.com/status.json`
25
26The 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.
27
28> `at://` is the URL scheme of the AT Protocol.
29
30## Step 1. Starting with our ExpressJS app
31
32Start by cloning the repo and installing packages.
33
34```bash
35git clone TODO
36cd TODO
37npm i
38npm run dev # you can leave this running and it will auto-reload
39```
40
41Our 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).
42
43Our starting stack:
44
45- Typescript
46- NodeJS web server ([express](#todo))
47- SQLite database ([Kysley](#todo))
48- Server-side rendering ([uhtml](#todo))
49
50With each step we'll explain how our Web app taps into the Atmosphere. Refer to the codebase for more detailed code — again, this tutorial is going to keep it light and quick to digest.
51
52## Step 2. Signing in with OAuth
53
54When 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.
55
56We'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:
57
58
59
60When 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.
61
62
63
64Our 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`).
65
66```html
67<!-- src/pages/login.ts -->
68<form action="/login" method="post" class="login-form">
69 <input
70 type="text"
71 name="handle"
72 placeholder="Enter your handle (eg alice.bsky.social)"
73 required
74 />
75 <button type="submit">Log in</button>
76</form>
77```
78
79When 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.
80
81```typescript
82/** src/routes.ts **/
83// Login handler
84router.post(
85 '/login',
86 handler(async (req, res) => {
87 // Initiate the OAuth flow
88 const url = await oauthClient.authorize(handle)
89 return res.redirect(url.toString())
90 })
91)
92```
93
94This 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.
95
96When 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.
97
98```typescript
99/** src/routes.ts **/
100// OAuth callback to complete session creation
101router.get(
102 '/oauth/callback',
103 handler(async (req, res) => {
104 // Store the credentials
105 const { agent } = await oauthClient.callback(params)
106
107 // Attach the account DID to our user via a cookie
108 const session = await getIronSession(req, res)
109 session.did = agent.accountDid
110 await session.save()
111
112 // Send them back to the app
113 return res.redirect('/')
114 })
115)
116```
117
118With 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.
119
120## Step 3. Fetching the user's profile
121
122Why 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.
123
124```typescript
125/** src/routes.ts **/
126async function getSessionAgent(
127 req: IncomingMessage,
128 res: ServerResponse<IncomingMessage>,
129 ctx: AppContext
130) {
131 // Fetch the session from their cookie
132 const session = await getIronSession(req, res)
133 if (!session.did) return null
134
135 // "Restore" the agent for the user
136 try {
137 return await ctx.oauthClient.restore(session.did)
138 } catch(err) {
139 ctx.logger.warn({ err }, 'oauth restore failed')
140 await session.destroy()
141 return null
142 }
143}
144```
145
146Users publish JSON records on their `at://` repos. In [Bluesky](https://bsky.app), they publish a "profile" record which looks like this:
147
148```typescript
149interface ProfileRecord {
150 displayName?: string // a human friendly name
151 description?: string // a short bio
152 avatar?: BlobRef // small profile picture
153 banner?: BlobRef // banner image to put on profiles
154 createdAt?: string // declared time this profile data was added
155 // ...
156}
157```
158
159We're going to use the [Agent](#todo) to fetch this record to include in our app.
160
161```typescript
162await agent.getRecord({
163 repo: agent.accountDid, // The user
164 collection: 'app.bsky.actor.profile', // The collection
165 rkey: 'self', // The record key
166})
167```
168
169When asking for a record, we provide three pieces of information.
170
171- The [DID](#todo) which identifies the user,
172- The collection name, and
173- The record key
174
175We'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.
176
177Let's update our homepage to fetch this profile record:
178
179```typescript
180/** src/routes.ts **/
181// Homepage
182router.get(
183 '/',
184 handler(async (req, res) => {
185 // If the user is signed in, get an agent which communicates with their server
186 const agent = await getSessionAgent(req, res, ctx)
187
188 if (!agent) {
189 // Serve the logged-out view
190 return res.type('html').send(page(home()))
191 }
192
193 // Fetch additional information about the logged-in user
194 const { data: profileRecord } = await agent.getRecord({
195 repo: agent.accountDid, // our user's repo
196 collection: 'app.bsky.actor.profile', // the bluesky profile record type
197 rkey: 'self', // the record's name
198 })
199
200 // Serve the logged-in view
201 return res
202 .type('html')
203 .send(page(home({ profile: profileRecord.value || {} })))
204 })
205)
206```
207
208With that data, we can give a nice personalized welcome banner for our user:
209
210```html
211<!-- pages/home.ts -->
212<div class="card">
213 ${profile
214 ? html`<form action="/logout" method="post" class="session-form">
215 <div>
216 Hi, <strong>${profile.displayName || 'friend'}</strong>.
217 What's your status today?
218 </div>
219 <div>
220 <button type="submit">Log out</button>
221 </div>
222 </form>`
223 : html`<div class="session-form">
224 <div><a href="/login">Log in</a> to set your status!</div>
225 <div>
226 <a href="/login" class="button">Log in</a>
227 </div>
228 </div>`}
229</div>
230```
231
232
233
234You 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).
235
236## Step 4. Reading & writing records
237
238You can think of the user repositories as collections of JSON records:
239
240
241
242Let's look again at how we read the "profile" record:
243
244```typescript
245await agent.getRecord({
246 repo: agent.accountDid, // The user
247 collection: 'app.bsky.actor.profile', // The collection
248 rkey: 'self', // The record key
249})
250```
251
252We write records using a similar API. Since our goal is to write "status" records, let's look at how that will happen:
253
254```typescript
255// Generate a time-based key for our record
256const rkey = TID.nextStr()
257
258// Write the
259await agent.putRecord({
260 repo: agent.accountDid, // The user
261 collection: 'com.example.status', // The collection
262 rkey, // The record key
263 record: { // The record value
264 status: "👍",
265 createdAt: new Date().toISOString()
266 }
267})
268```
269
270Our `POST /status` route is going to use this API to publish the user's status to their repo.
271
272```typescript
273/** src/routes.ts **/
274// "Set status" handler
275router.post(
276 '/status',
277 handler(async (req, res) => {
278 // If the user is signed in, get an agent which communicates with their server
279 const agent = await getSessionAgent(req, res, ctx)
280 if (!agent) {
281 return res.status(401).json({ error: 'Session required' })
282 }
283
284 // Construct their status record
285 const record = {
286 $type: 'com.example.status',
287 status: req.body?.status,
288 createdAt: new Date().toISOString(),
289 }
290
291 try {
292 // Write the status record to the user's repository
293 await agent.putRecord({
294 repo: agent.accountDid,
295 collection: 'com.example.status',
296 rkey: TID.nextStr(),
297 record,
298 })
299 } catch (err) {
300 logger.warn({ err }, 'failed to write record')
301 return res.status(500).json({ error: 'Failed to write record' })
302 }
303
304 res.status(200).json({})
305 })
306)
307```
308
309Now in our homepage we can list out the status buttons:
310
311```html
312<!-- src/pages/home.ts -->
313<div class="status-options">
314 ${['👍', '🦋', '🥳', /*...*/].map(status => html`
315 <div class="status-option" data-value="${status}">
316 ${status}
317 </div>`
318 )}
319</div>
320```
321
322And write some client-side javascript to submit the status on click:
323
324```javascript
325/* src/pages/public/home.js */
326Array.from(document.querySelectorAll('.status-option'), (el) => {
327 el.addEventListener('click', async (ev) => {
328 const res = await fetch('/status', {
329 method: 'POST',
330 headers: { 'content-type': 'application/json' },
331 body: JSON.stringify({ status: el.dataset.value }),
332 })
333 const body = await res.json()
334 if (!body?.error) {
335 location.reload()
336 }
337 })
338})
339```
340
341And here we are!
342
343
344
345## Step 5. Creating a custom "status" schema
346
347The 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).
348
349Anybody 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.
350
351> ### Why create a schema?
352>
353> 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.
354
355Let's create our schema in the `/lexicons` folder of our codebase. You can [read more about how to define schemas here](#todo).
356
357```json
358/* lexicons/status.json */
359{
360 "lexicon": 1,
361 "id": "com.example.status",
362 "defs": {
363 "main": {
364 "type": "record",
365 "key": "tid",
366 "record": {
367 "type": "object",
368 "required": ["status", "createdAt"],
369 "properties": {
370 "status": {
371 "type": "string",
372 "minLength": 1,
373 "maxGraphemes": 1,
374 "maxLength": 32
375 },
376 "createdAt": {
377 "type": "string",
378 "format": "datetime"
379 }
380 }
381 }
382 }
383 }
384}
385```
386
387Now let's run some code-generation using our schema:
388
389```bash
390./node_modules/.bin/lex gen-server ./src/lexicon ./lexicons/*
391```
392
393This will produce Typescript interfaces as well as runtime validation functions that we can use in our `POST /status` route:
394
395```typescript
396/** src/routes.ts **/
397import * as Status from '#/lexicon/types/com/example/status'
398// ...
399// "Set status" handler
400router.post(
401 '/status',
402 handler(async (req, res) => {
403 // ...
404
405 // Construct & validate their status record
406 const record = {
407 $type: 'com.example.status',
408 status: req.body?.status,
409 createdAt: new Date().toISOString(),
410 }
411 if (!Status.validateRecord(record).success) {
412 return res.status(400).json({ error: 'Invalid status' })
413 }
414
415 // ...
416 })
417)
418```
419
420## Step 6. Listening to the firehose
421
422So far, we have:
423
424- Logged in via OAuth
425- Created a custom schema
426- Read & written records for the logged in user
427
428Now we want to fetch the status records from other users.
429
430Remember 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.
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
510Why 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.
511
512## Step 7. Listing the latest statuses
513
514Now 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:
515
516```typescript
517/** src/routes.ts **/
518// Homepage
519router.get(
520 '/',
521 handler(async (req, res) => {
522 // ...
523
524 // Fetch data stored in our SQLite
525 const statuses = await db
526 .selectFrom('status')
527 .selectAll()
528 .orderBy('indexedAt', 'desc')
529 .limit(10)
530 .execute()
531
532 // Map user DIDs to their domain-name handles
533 const didHandleMap = await resolver.resolveDidsToHandles(
534 statuses.map((s) => s.authorDid)
535 )
536
537 // ...
538 })
539)
540```
541
542Our HTML can now list these status records:
543
544```html
545<!-- src/pages/home.ts -->
546${statuses.map((status, i) => {
547 const handle = didHandleMap[status.authorDid] || status.authorDid
548 return html`
549 <div class="status-line">
550 <div>
551 <div class="status">${status.status}</div>
552 </div>
553 <div class="desc">
554 <a class="author" href="https://bsky.app/profile/${handle}">@${handle}</a>
555 was feeling ${status.status} on ${status.indexedAt}.
556 </div>
557 </div>
558 `
559})}
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
570This 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.
571
572To do this, we just update `POST /status` to include an additional write to our SQLite DB:
573
574```typescript
575/** src/routes.ts **/
576// "Set status" handler
577router.post(
578 '/status',
579 handler(async (req, res) => {
580 // ...
581
582 let uri
583 try {
584 // Write the status record to the user's repository
585 const res = await agent.putRecord({
586 repo: agent.accountDid,
587 collection: 'com.example.status',
588 rkey: TID.nextStr(),
589 record,
590 })
591 uri = res.uri
592 } catch (err) {
593 logger.warn({ err }, 'failed to write record')
594 return res.status(500).json({ error: 'Failed to write record' })
595 }
596
597 try {
598 // Optimistically update our SQLite <-- HERE!
599 await db
600 .insertInto('status')
601 .values({
602 uri,
603 authorDid: agent.accountDid,
604 status: record.status,
605 createdAt: record.createdAt,
606 indexedAt: new Date().toISOString(),
607 })
608 .execute()
609 } catch (err) {
610 logger.warn(
611 { err },
612 'failed to update computed view; ignoring as it should be caught by the firehose'
613 )
614 }
615
616 res.status(200).json({})
617 })
618)
619```
620
621You'll notice this code looks almost exactly like what we're doing in `firehose.ts`.
622
623## Thinking in AT Proto
624
625In this tutorial we've covered the key steps to building an atproto app. Data is published in its canonical form on users' `at://` repos and then aggregated into apps' databases to produce views of the network.
626
627When building your app, think in these four key steps:
628
629- Design the [Lexicon](#) schemas for the records you'll publish into the Atmosphere.
630- Create a database for aggregating the records into useful views.
631- Build your application to write the records on your users' repos.
632- Listen to the firehose to hydrate your aggregated database.
633
634Remember this flow of information throughout:
635
636
637
638This is how every app in the Atmosphere works, including the [Bluesky social app](https://bsky.app).
639
640## Next steps
641
642TODO
643
644