···1010 const firehose = new Firehose({})
11111212 for await (const evt of firehose.run()) {
1313- if (evt.event === 'create') {
1313+ // Watch for write events
1414+ if (evt.event === 'create' || evt.event === 'update') {
1415 const record = evt.record
1616+1717+ // If the write is a valid status update
1518 if (
1619 evt.collection === 'com.example.status' &&
1720 Status.isRecord(record) &&
1821 Status.validateRecord(record).success
1922 ) {
2323+ // Store the status in our SQLite
2024 await this.db
2125 .insertInto('status')
2226 .values({
···2529 updatedAt: record.updatedAt,
2630 indexedAt: new Date().toISOString(),
2731 })
2828- .onConflict((oc) => oc.doNothing())
3232+ .onConflict((oc) =>
3333+ oc.column('authorDid').doUpdateSet({
3434+ status: record.status,
3535+ updatedAt: record.updatedAt,
3636+ indexedAt: new Date().toISOString(),
3737+ })
3838+ )
2939 .execute()
3040 }
3141 }
+14-12
src/index.ts
···55import type { OAuthClient } from '@atproto/oauth-client-node'
6677import { createDb, migrateToLatest } from '#/db'
88-import { env } from '#/env'
88+import { env } from '#/lib/env'
99import { Ingester } from '#/firehose/ingester'
1010import { createRouter } from '#/routes'
1111import { createClient } from '#/auth/client'
1212import { createResolver, Resolver } from '#/firehose/resolver'
1313import type { Database } from '#/db'
14141515+// Application state passed to the router and elsewhere
1516export type AppContext = {
1617 db: Database
1718 ingester: Ingester
···29303031 static async create() {
3132 const { NODE_ENV, HOST, PORT, DB_PATH } = env
3232-3333 const logger = pino({ name: 'server start' })
3434+3535+ // Set up the SQLite database
3436 const db = createDb(DB_PATH)
3537 await migrateToLatest(db)
3636- const ingester = new Ingester(db)
3838+3939+ // Create the atproto utilities
3740 const oauthClient = await createClient(db)
4141+ const ingester = new Ingester(db)
3842 const resolver = createResolver()
3939- ingester.start()
4043 const ctx = {
4144 db,
4245 ingester,
···4548 resolver,
4649 }
47504848- const app: Express = express()
5151+ // Subscribe to events on the firehose
5252+ ingester.start()
49535050- // Set the application to trust the reverse proxy
5454+ // Create our server
5555+ const app: Express = express()
5156 app.set('trust proxy', true)
52575353- // Middlewares
5858+ // Routes & middlewares
5959+ const router = createRouter(ctx)
5460 app.use(express.json())
5561 app.use(express.urlencoded({ extended: true }))
5656-5757- // Routes
5858- const router = createRouter(ctx)
5962 app.use(router)
6060-6161- // Error handlers
6263 app.use((_req, res) => res.sendStatus(404))
63646565+ // Bind our server to the port
6466 const server = app.listen(env.PORT)
6567 await events.once(server, 'listening')
6668 logger.info(`Server (${NODE_ENV}) running on port http://${HOST}:${PORT}`)
+1-2
src/pages/home.ts
···11-import { AtUri } from '@atproto/syntax'
21import type { Status } from '#/db/schema'
33-import { html } from '../view'
22+import { html } from '../lib/view'
43import { shell } from './shell'
5465const TODAY = new Date().toDateString()
+1-1
src/pages/login.ts
···11-import { html } from '../view'
11+import { html } from '../lib/view'
22import { shell } from './shell'
3344type Props = { error?: string }
+1-1
src/pages/shell.ts
···11-import { type Hole, html } from '../view'
11+import { type Hole, html } from '../lib/view'
2233export function shell({ title, content }: { title: string; content: Hole }) {
44 return html`<html>
+32-1
src/routes.ts
···66import type { AppContext } from '#/index'
77import { home } from '#/pages/home'
88import { login } from '#/pages/login'
99-import { page } from '#/view'
99+import { page } from '#/lib/view'
1010import * as Status from '#/lexicon/types/com/example/status'
11111212+// Helper function for defining routes
1213const handler =
1314 (fn: express.Handler) =>
1415 async (
···2627export const createRouter = (ctx: AppContext) => {
2728 const router = express.Router()
28293030+ // Static assets
2931 router.use('/public', express.static(path.join(__dirname, 'pages', 'public')))
30323333+ // OAuth metadata
3134 router.get(
3235 '/client-metadata.json',
3336 handler((_req, res) => {
···3538 })
3639 )
37404141+ // OAuth callback to complete session creation
3842 router.get(
3943 '/oauth/callback',
4044 handler(async (req, res) => {
···5054 })
5155 )
52565757+ // Login page
5358 router.get(
5459 '/login',
5560 handler(async (_req, res) => {
···5762 })
5863 )
59646565+ // Login handler
6066 router.post(
6167 '/login',
6268 handler(async (req, res) => {
6969+ // Validate
6370 const handle = req.body?.handle
6471 if (typeof handle !== 'string' || !isValidHandle(handle)) {
6572 return res.type('html').send(page(login({ error: 'invalid handle' })))
6673 }
7474+7575+ // Initiate the OAuth flow
6776 try {
6877 const url = await ctx.oauthClient.authorize(handle)
6978 return res.redirect(url.toString())
···8392 })
8493 )
85949595+ // Logout handler
8696 router.post(
8797 '/logout',
8898 handler(async (req, res) => {
···91101 })
92102 )
93103104104+ // Homepage
94105 router.get(
95106 '/',
96107 handler(async (req, res) => {
108108+ // If the user is signed in, get an agent which communicates with their server
97109 const agent = await getSessionAgent(req, res, ctx)
110110+111111+ // Fetch data stored in our SQLite
98112 const statuses = await ctx.db
99113 .selectFrom('status')
100114 .selectAll()
···108122 .where('authorDid', '=', agent.accountDid)
109123 .executeTakeFirst()
110124 : undefined
125125+126126+ // Map user DIDs to their domain-name handles
111127 const didHandleMap = await ctx.resolver.resolveDidsToHandles(
112128 statuses.map((s) => s.authorDid)
113129 )
130130+114131 if (!agent) {
132132+ // Serve the logged-out view
115133 return res.type('html').send(page(home({ statuses, didHandleMap })))
116134 }
135135+136136+ // Fetch additional information about the logged-in user
117137 const { data: profile } = await agent.getProfile({
118138 actor: agent.accountDid,
119139 })
140140+ didHandleMap[profile.handle] = agent.accountDid
141141+142142+ // Serve the logged-in view
120143 return res
121144 .type('html')
122145 .send(page(home({ statuses, didHandleMap, profile, myStatus })))
123146 })
124147 )
125148149149+ // "Set status" handler
126150 router.post(
127151 '/status',
128152 handler(async (req, res) => {
153153+ // If the user is signed in, get an agent which communicates with their server
129154 const agent = await getSessionAgent(req, res, ctx)
130155 if (!agent) {
131156 return res.status(401).json({ error: 'Session required' })
132157 }
133158159159+ // Construct & validate their status record
134160 const record = {
135161 $type: 'com.example.status',
136162 status: req.body?.status,
···141167 }
142168143169 try {
170170+ // Write the status record to the user's repository
144171 await agent.com.atproto.repo.putRecord({
145172 repo: agent.accountDid,
146173 collection: 'com.example.status',
···154181 }
155182156183 try {
184184+ // Optimistically update our SQLite
185185+ // This isn't strictly necessary because the write event will be
186186+ // handled in #/firehose/ingestor.ts, but it ensures that future reads
187187+ // will be up-to-date after this method finishes.
157188 await ctx.db
158189 .insertInto('status')
159190 .values({