the statusphere demo reworked into a vite/react app in a monorepo

Merge pull request #8 from bluesky-social/paul/further-repo-cleanup

Further repo cleanup

authored by

Paul Frazee and committed by
GitHub
74dc0f96 e9b9ead1

+69 -22
+1 -1
src/auth/client.ts
··· 1 import { NodeOAuthClient } from '@atproto/oauth-client-node' 2 import type { Database } from '#/db' 3 - import { env } from '#/env' 4 import { SessionStore, StateStore } from './storage' 5 6 export const createClient = async (db: Database) => {
··· 1 import { NodeOAuthClient } from '@atproto/oauth-client-node' 2 import type { Database } from '#/db' 3 + import { env } from '#/lib/env' 4 import { SessionStore, StateStore } from './storage' 5 6 export const createClient = async (db: Database) => {
+1 -1
src/auth/session.ts
··· 1 import assert from 'node:assert' 2 import type { IncomingMessage, ServerResponse } from 'node:http' 3 import { getIronSession } from 'iron-session' 4 - import { env } from '#/env' 5 import { AppContext } from '#/index' 6 7 export type Session = { did: string }
··· 1 import assert from 'node:assert' 2 import type { IncomingMessage, ServerResponse } from 'node:http' 3 import { getIronSession } from 'iron-session' 4 + import { env } from '#/lib/env' 5 import { AppContext } from '#/index' 6 7 export type Session = { did: string }
src/env.ts src/lib/env.ts
+6 -1
src/firehose/firehose.ts
··· 57 if (isCommit(evt) && !this.opts.excludeCommit) { 58 const parsed = await parseCommit(evt) 59 for (const write of parsed) { 60 - if (!this.opts.filterCollections || this.opts.filterCollections.includes(write.uri.collection)) { 61 yield write 62 } 63 } ··· 167 168 type Update = CommitMeta & { 169 event: 'update' 170 } 171 172 type Delete = CommitMeta & {
··· 57 if (isCommit(evt) && !this.opts.excludeCommit) { 58 const parsed = await parseCommit(evt) 59 for (const write of parsed) { 60 + if ( 61 + !this.opts.filterCollections || 62 + this.opts.filterCollections.includes(write.uri.collection) 63 + ) { 64 yield write 65 } 66 } ··· 170 171 type Update = CommitMeta & { 172 event: 'update' 173 + record: RepoRecord 174 + cid: CID 175 } 176 177 type Delete = CommitMeta & {
+12 -2
src/firehose/ingester.ts
··· 10 const firehose = new Firehose({}) 11 12 for await (const evt of firehose.run()) { 13 - if (evt.event === 'create') { 14 const record = evt.record 15 if ( 16 evt.collection === 'com.example.status' && 17 Status.isRecord(record) && 18 Status.validateRecord(record).success 19 ) { 20 await this.db 21 .insertInto('status') 22 .values({ ··· 25 updatedAt: record.updatedAt, 26 indexedAt: new Date().toISOString(), 27 }) 28 - .onConflict((oc) => oc.doNothing()) 29 .execute() 30 } 31 }
··· 10 const firehose = new Firehose({}) 11 12 for await (const evt of firehose.run()) { 13 + // Watch for write events 14 + if (evt.event === 'create' || evt.event === 'update') { 15 const record = evt.record 16 + 17 + // If the write is a valid status update 18 if ( 19 evt.collection === 'com.example.status' && 20 Status.isRecord(record) && 21 Status.validateRecord(record).success 22 ) { 23 + // Store the status in our SQLite 24 await this.db 25 .insertInto('status') 26 .values({ ··· 29 updatedAt: record.updatedAt, 30 indexedAt: new Date().toISOString(), 31 }) 32 + .onConflict((oc) => 33 + oc.column('authorDid').doUpdateSet({ 34 + status: record.status, 35 + updatedAt: record.updatedAt, 36 + indexedAt: new Date().toISOString(), 37 + }) 38 + ) 39 .execute() 40 } 41 }
+14 -12
src/index.ts
··· 5 import type { OAuthClient } from '@atproto/oauth-client-node' 6 7 import { createDb, migrateToLatest } from '#/db' 8 - import { env } from '#/env' 9 import { Ingester } from '#/firehose/ingester' 10 import { createRouter } from '#/routes' 11 import { createClient } from '#/auth/client' 12 import { createResolver, Resolver } from '#/firehose/resolver' 13 import type { Database } from '#/db' 14 15 export type AppContext = { 16 db: Database 17 ingester: Ingester ··· 29 30 static async create() { 31 const { NODE_ENV, HOST, PORT, DB_PATH } = env 32 - 33 const logger = pino({ name: 'server start' }) 34 const db = createDb(DB_PATH) 35 await migrateToLatest(db) 36 - const ingester = new Ingester(db) 37 const oauthClient = await createClient(db) 38 const resolver = createResolver() 39 - ingester.start() 40 const ctx = { 41 db, 42 ingester, ··· 45 resolver, 46 } 47 48 - const app: Express = express() 49 50 - // Set the application to trust the reverse proxy 51 app.set('trust proxy', true) 52 53 - // Middlewares 54 app.use(express.json()) 55 app.use(express.urlencoded({ extended: true })) 56 - 57 - // Routes 58 - const router = createRouter(ctx) 59 app.use(router) 60 - 61 - // Error handlers 62 app.use((_req, res) => res.sendStatus(404)) 63 64 const server = app.listen(env.PORT) 65 await events.once(server, 'listening') 66 logger.info(`Server (${NODE_ENV}) running on port http://${HOST}:${PORT}`)
··· 5 import type { OAuthClient } from '@atproto/oauth-client-node' 6 7 import { createDb, migrateToLatest } from '#/db' 8 + import { env } from '#/lib/env' 9 import { Ingester } from '#/firehose/ingester' 10 import { createRouter } from '#/routes' 11 import { createClient } from '#/auth/client' 12 import { createResolver, Resolver } from '#/firehose/resolver' 13 import type { Database } from '#/db' 14 15 + // Application state passed to the router and elsewhere 16 export type AppContext = { 17 db: Database 18 ingester: Ingester ··· 30 31 static async create() { 32 const { NODE_ENV, HOST, PORT, DB_PATH } = env 33 const logger = pino({ name: 'server start' }) 34 + 35 + // Set up the SQLite database 36 const db = createDb(DB_PATH) 37 await migrateToLatest(db) 38 + 39 + // Create the atproto utilities 40 const oauthClient = await createClient(db) 41 + const ingester = new Ingester(db) 42 const resolver = createResolver() 43 const ctx = { 44 db, 45 ingester, ··· 48 resolver, 49 } 50 51 + // Subscribe to events on the firehose 52 + ingester.start() 53 54 + // Create our server 55 + const app: Express = express() 56 app.set('trust proxy', true) 57 58 + // Routes & middlewares 59 + const router = createRouter(ctx) 60 app.use(express.json()) 61 app.use(express.urlencoded({ extended: true })) 62 app.use(router) 63 app.use((_req, res) => res.sendStatus(404)) 64 65 + // Bind our server to the port 66 const server = app.listen(env.PORT) 67 await events.once(server, 'listening') 68 logger.info(`Server (${NODE_ENV}) running on port http://${HOST}:${PORT}`)
+1 -2
src/pages/home.ts
··· 1 - import { AtUri } from '@atproto/syntax' 2 import type { Status } from '#/db/schema' 3 - import { html } from '../view' 4 import { shell } from './shell' 5 6 const TODAY = new Date().toDateString()
··· 1 import type { Status } from '#/db/schema' 2 + import { html } from '../lib/view' 3 import { shell } from './shell' 4 5 const TODAY = new Date().toDateString()
+1 -1
src/pages/login.ts
··· 1 - import { html } from '../view' 2 import { shell } from './shell' 3 4 type Props = { error?: string }
··· 1 + import { html } from '../lib/view' 2 import { shell } from './shell' 3 4 type Props = { error?: string }
+1 -1
src/pages/shell.ts
··· 1 - import { type Hole, html } from '../view' 2 3 export function shell({ title, content }: { title: string; content: Hole }) { 4 return html`<html>
··· 1 + import { type Hole, html } from '../lib/view' 2 3 export function shell({ title, content }: { title: string; content: Hole }) { 4 return html`<html>
+32 -1
src/routes.ts
··· 6 import type { AppContext } from '#/index' 7 import { home } from '#/pages/home' 8 import { login } from '#/pages/login' 9 - import { page } from '#/view' 10 import * as Status from '#/lexicon/types/com/example/status' 11 12 const handler = 13 (fn: express.Handler) => 14 async ( ··· 26 export const createRouter = (ctx: AppContext) => { 27 const router = express.Router() 28 29 router.use('/public', express.static(path.join(__dirname, 'pages', 'public'))) 30 31 router.get( 32 '/client-metadata.json', 33 handler((_req, res) => { ··· 35 }) 36 ) 37 38 router.get( 39 '/oauth/callback', 40 handler(async (req, res) => { ··· 50 }) 51 ) 52 53 router.get( 54 '/login', 55 handler(async (_req, res) => { ··· 57 }) 58 ) 59 60 router.post( 61 '/login', 62 handler(async (req, res) => { 63 const handle = req.body?.handle 64 if (typeof handle !== 'string' || !isValidHandle(handle)) { 65 return res.type('html').send(page(login({ error: 'invalid handle' }))) 66 } 67 try { 68 const url = await ctx.oauthClient.authorize(handle) 69 return res.redirect(url.toString()) ··· 83 }) 84 ) 85 86 router.post( 87 '/logout', 88 handler(async (req, res) => { ··· 91 }) 92 ) 93 94 router.get( 95 '/', 96 handler(async (req, res) => { 97 const agent = await getSessionAgent(req, res, ctx) 98 const statuses = await ctx.db 99 .selectFrom('status') 100 .selectAll() ··· 108 .where('authorDid', '=', agent.accountDid) 109 .executeTakeFirst() 110 : undefined 111 const didHandleMap = await ctx.resolver.resolveDidsToHandles( 112 statuses.map((s) => s.authorDid) 113 ) 114 if (!agent) { 115 return res.type('html').send(page(home({ statuses, didHandleMap }))) 116 } 117 const { data: profile } = await agent.getProfile({ 118 actor: agent.accountDid, 119 }) 120 return res 121 .type('html') 122 .send(page(home({ statuses, didHandleMap, profile, myStatus }))) 123 }) 124 ) 125 126 router.post( 127 '/status', 128 handler(async (req, res) => { 129 const agent = await getSessionAgent(req, res, ctx) 130 if (!agent) { 131 return res.status(401).json({ error: 'Session required' }) 132 } 133 134 const record = { 135 $type: 'com.example.status', 136 status: req.body?.status, ··· 141 } 142 143 try { 144 await agent.com.atproto.repo.putRecord({ 145 repo: agent.accountDid, 146 collection: 'com.example.status', ··· 154 } 155 156 try { 157 await ctx.db 158 .insertInto('status') 159 .values({
··· 6 import type { AppContext } from '#/index' 7 import { home } from '#/pages/home' 8 import { login } from '#/pages/login' 9 + import { page } from '#/lib/view' 10 import * as Status from '#/lexicon/types/com/example/status' 11 12 + // Helper function for defining routes 13 const handler = 14 (fn: express.Handler) => 15 async ( ··· 27 export const createRouter = (ctx: AppContext) => { 28 const router = express.Router() 29 30 + // Static assets 31 router.use('/public', express.static(path.join(__dirname, 'pages', 'public'))) 32 33 + // OAuth metadata 34 router.get( 35 '/client-metadata.json', 36 handler((_req, res) => { ··· 38 }) 39 ) 40 41 + // OAuth callback to complete session creation 42 router.get( 43 '/oauth/callback', 44 handler(async (req, res) => { ··· 54 }) 55 ) 56 57 + // Login page 58 router.get( 59 '/login', 60 handler(async (_req, res) => { ··· 62 }) 63 ) 64 65 + // Login handler 66 router.post( 67 '/login', 68 handler(async (req, res) => { 69 + // Validate 70 const handle = req.body?.handle 71 if (typeof handle !== 'string' || !isValidHandle(handle)) { 72 return res.type('html').send(page(login({ error: 'invalid handle' }))) 73 } 74 + 75 + // Initiate the OAuth flow 76 try { 77 const url = await ctx.oauthClient.authorize(handle) 78 return res.redirect(url.toString()) ··· 92 }) 93 ) 94 95 + // Logout handler 96 router.post( 97 '/logout', 98 handler(async (req, res) => { ··· 101 }) 102 ) 103 104 + // Homepage 105 router.get( 106 '/', 107 handler(async (req, res) => { 108 + // If the user is signed in, get an agent which communicates with their server 109 const agent = await getSessionAgent(req, res, ctx) 110 + 111 + // Fetch data stored in our SQLite 112 const statuses = await ctx.db 113 .selectFrom('status') 114 .selectAll() ··· 122 .where('authorDid', '=', agent.accountDid) 123 .executeTakeFirst() 124 : undefined 125 + 126 + // Map user DIDs to their domain-name handles 127 const didHandleMap = await ctx.resolver.resolveDidsToHandles( 128 statuses.map((s) => s.authorDid) 129 ) 130 + 131 if (!agent) { 132 + // Serve the logged-out view 133 return res.type('html').send(page(home({ statuses, didHandleMap }))) 134 } 135 + 136 + // Fetch additional information about the logged-in user 137 const { data: profile } = await agent.getProfile({ 138 actor: agent.accountDid, 139 }) 140 + didHandleMap[profile.handle] = agent.accountDid 141 + 142 + // Serve the logged-in view 143 return res 144 .type('html') 145 .send(page(home({ statuses, didHandleMap, profile, myStatus }))) 146 }) 147 ) 148 149 + // "Set status" handler 150 router.post( 151 '/status', 152 handler(async (req, res) => { 153 + // If the user is signed in, get an agent which communicates with their server 154 const agent = await getSessionAgent(req, res, ctx) 155 if (!agent) { 156 return res.status(401).json({ error: 'Session required' }) 157 } 158 159 + // Construct & validate their status record 160 const record = { 161 $type: 'com.example.status', 162 status: req.body?.status, ··· 167 } 168 169 try { 170 + // Write the status record to the user's repository 171 await agent.com.atproto.repo.putRecord({ 172 repo: agent.accountDid, 173 collection: 'com.example.status', ··· 181 } 182 183 try { 184 + // Optimistically update our SQLite 185 + // This isn't strictly necessary because the write event will be 186 + // handled in #/firehose/ingestor.ts, but it ensures that future reads 187 + // will be up-to-date after this method finishes. 188 await ctx.db 189 .insertInto('status') 190 .values({
src/view.ts src/lib/view.ts