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

Merge pull request #3 from bluesky-social/paul/status-app

Implement status update app

authored by

Paul Frazee and committed by
GitHub
5a0ccb65 e92849c1

+579 -91
+1
.env.template
··· 3 3 PORT="8080" # The port your server will listen on 4 4 HOST="localhost" # Hostname for the server 5 5 PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id. 6 + DB_PATH=":memory:" # The SQLite database path. Leave as ":memory:" to use a temporary in-memory database. 6 7 7 8 # CORS Settings 8 9 CORS_ORIGIN="http://localhost:*" # Allowed CORS origin, adjust as necessary
+7 -2
lexicons/status.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "example.lexicon.status", 3 + "id": "com.example.status", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", ··· 9 9 "type": "object", 10 10 "required": ["status", "updatedAt"], 11 11 "properties": { 12 - "status": { "type": "string" }, 12 + "status": { 13 + "type": "string", 14 + "minLength": 1, 15 + "maxGraphemes": 1, 16 + "maxLength": 32 17 + }, 13 18 "updatedAt": { "type": "string", "format": "datetime" } 14 19 } 15 20 }
+9 -2
package.json
··· 18 18 "test": "vitest run" 19 19 }, 20 20 "dependencies": { 21 + "@atproto/identity": "^0.4.0", 21 22 "@atproto/jwk-jose": "0.1.2-rc.0", 22 23 "@atproto/lexicon": "0.4.1-rc.0", 23 24 "@atproto/oauth-client-node": "0.0.2-rc.2", ··· 57 58 "vitest": "^2.0.0" 58 59 }, 59 60 "lint-staged": { 60 - "*.{js,ts,cjs,mjs,d.cts,d.mts,json,jsonc}": ["biome check --apply --no-errors-on-unmatched"] 61 + "*.{js,ts,cjs,mjs,d.cts,d.mts,json,jsonc}": [ 62 + "biome check --apply --no-errors-on-unmatched" 63 + ] 61 64 }, 62 65 "tsup": { 63 - "entry": ["src", "!src/**/__tests__/**", "!src/**/*.test.*"], 66 + "entry": [ 67 + "src", 68 + "!src/**/__tests__/**", 69 + "!src/**/*.test.*" 70 + ], 64 71 "splitting": false, 65 72 "sourcemap": true, 66 73 "clean": true
+2
src/config.ts
··· 2 2 import type pino from 'pino' 3 3 import type { Database } from '#/db' 4 4 import type { Ingester } from '#/firehose/ingester' 5 + import { Resolver } from '#/ident/types' 5 6 6 7 export type AppContext = { 7 8 db: Database 8 9 ingester: Ingester 9 10 logger: pino.Logger 10 11 oauthClient: OAuthClient 12 + resolver: Resolver 11 13 }
+12 -4
src/db/migrations.ts
··· 11 11 migrations['001'] = { 12 12 async up(db: Kysely<unknown>) { 13 13 await db.schema 14 - .createTable('post') 15 - .addColumn('uri', 'varchar', (col) => col.primaryKey()) 16 - .addColumn('text', 'varchar', (col) => col.notNull()) 14 + .createTable('did_cache') 15 + .addColumn('did', 'varchar', (col) => col.primaryKey()) 16 + .addColumn('doc', 'varchar', (col) => col.notNull()) 17 + .addColumn('updatedAt', 'varchar', (col) => col.notNull()) 18 + .execute() 19 + await db.schema 20 + .createTable('status') 21 + .addColumn('authorDid', 'varchar', (col) => col.primaryKey()) 22 + .addColumn('status', 'varchar', (col) => col.notNull()) 23 + .addColumn('updatedAt', 'varchar', (col) => col.notNull()) 17 24 .addColumn('indexedAt', 'varchar', (col) => col.notNull()) 18 25 .execute() 19 26 await db.schema ··· 30 37 async down(db: Kysely<unknown>) { 31 38 await db.schema.dropTable('auth_state').execute() 32 39 await db.schema.dropTable('auth_session').execute() 33 - await db.schema.dropTable('post').execute() 40 + await db.schema.dropTable('status').execute() 41 + await db.schema.dropTable('did_cache').execute() 34 42 }, 35 43 }
+12 -4
src/db/schema.ts
··· 1 1 export type DatabaseSchema = { 2 - post: Post 2 + did_cache: DidCache 3 + status: Status 3 4 auth_session: AuthSession 4 5 auth_state: AuthState 5 6 } 6 7 7 - export type Post = { 8 - uri: string 9 - text: string 8 + export type DidCache = { 9 + did: string 10 + doc: string 11 + updatedAt: string 12 + } 13 + 14 + export type Status = { 15 + authorDid: string 16 + status: string 17 + updatedAt: string 10 18 indexedAt: string 11 19 } 12 20
+5 -1
src/env.ts
··· 4 4 dotenv.config() 5 5 6 6 export const env = cleanEnv(process.env, { 7 - NODE_ENV: str({ devDefault: testOnly('test'), choices: ['development', 'production', 'test'] }), 7 + NODE_ENV: str({ 8 + devDefault: testOnly('test'), 9 + choices: ['development', 'production', 'test'], 10 + }), 8 11 HOST: host({ devDefault: testOnly('localhost') }), 9 12 PORT: port({ devDefault: testOnly(3000) }), 10 13 PUBLIC_URL: str({}), 14 + DB_PATH: str({ devDefault: ':memory:' }), 11 15 COOKIE_SECRET: str({ devDefault: '00000000000000000000000000000000' }), 12 16 CORS_ORIGIN: str({ devDefault: testOnly('http://localhost:3000') }), 13 17 COMMON_RATE_LIMIT_MAX_REQUESTS: num({ devDefault: testOnly(1000) }),
+18 -11
src/firehose/ingester.ts
··· 1 1 import type { Database } from '#/db' 2 2 import { Firehose } from '#/firehose/firehose' 3 + import * as Status from '#/lexicon/types/com/example/status' 3 4 4 5 export class Ingester { 5 6 firehose: Firehose | undefined ··· 10 11 11 12 for await (const evt of firehose.run()) { 12 13 if (evt.event === 'create') { 13 - if (evt.collection !== 'app.bsky.feed.post') continue 14 - const post: any = evt.record // @TODO fix types 15 - await this.db 16 - .insertInto('post') 17 - .values({ 18 - uri: evt.uri.toString(), 19 - text: post.text as string, 20 - indexedAt: new Date().toISOString(), 21 - }) 22 - .onConflict((oc) => oc.doNothing()) 23 - .execute() 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({ 23 + authorDid: evt.author, 24 + status: record.status, 25 + updatedAt: record.updatedAt, 26 + indexedAt: new Date().toISOString(), 27 + }) 28 + .onConflict((oc) => oc.doNothing()) 29 + .execute() 30 + } 24 31 } 25 32 } 26 33 }
+88
src/ident/resolver.ts
··· 1 + import { IdResolver, DidDocument, CacheResult } from '@atproto/identity' 2 + import type { Database } from '#/db' 3 + 4 + const HOUR = 60e3 * 60 5 + const DAY = HOUR * 24 6 + 7 + export function createResolver(db: Database) { 8 + const resolver = new IdResolver({ 9 + didCache: { 10 + async cacheDid(did: string, doc: DidDocument): Promise<void> { 11 + await db 12 + .insertInto('did_cache') 13 + .values({ 14 + did, 15 + doc: JSON.stringify(doc), 16 + updatedAt: new Date().toISOString(), 17 + }) 18 + .onConflict((oc) => 19 + oc.column('did').doUpdateSet({ 20 + doc: JSON.stringify(doc), 21 + updatedAt: new Date().toISOString(), 22 + }) 23 + ) 24 + .execute() 25 + }, 26 + 27 + async checkCache(did: string): Promise<CacheResult | null> { 28 + const row = await db 29 + .selectFrom('did_cache') 30 + .selectAll() 31 + .where('did', '=', did) 32 + .executeTakeFirst() 33 + if (!row) return null 34 + const now = Date.now() 35 + const updatedAt = +new Date(row.updatedAt) 36 + return { 37 + did, 38 + doc: JSON.parse(row.doc), 39 + updatedAt, 40 + stale: now > updatedAt + HOUR, 41 + expired: now > updatedAt + DAY, 42 + } 43 + }, 44 + 45 + async refreshCache( 46 + did: string, 47 + getDoc: () => Promise<DidDocument | null> 48 + ): Promise<void> { 49 + const doc = await getDoc() 50 + if (doc) { 51 + await this.cacheDid(did, doc) 52 + } 53 + }, 54 + 55 + async clearEntry(did: string): Promise<void> { 56 + await db.deleteFrom('did_cache').where('did', '=', did).execute() 57 + }, 58 + 59 + async clear(): Promise<void> { 60 + await db.deleteFrom('did_cache').execute() 61 + }, 62 + }, 63 + }) 64 + 65 + return { 66 + async resolveDidToHandle(did: string): Promise<string> { 67 + const didDoc = await resolver.did.resolveAtprotoData(did) 68 + const resolvedHandle = await resolver.handle.resolve(didDoc.handle) 69 + if (resolvedHandle === did) { 70 + return didDoc.handle 71 + } 72 + return did 73 + }, 74 + 75 + async resolveDidsToHandles( 76 + dids: string[] 77 + ): Promise<Record<string, string>> { 78 + const didHandleMap: Record<string, string> = {} 79 + const resolves = await Promise.all( 80 + dids.map((did) => this.resolveDidToHandle(did).catch((_) => did)) 81 + ) 82 + for (let i = 0; i < dids.length; i++) { 83 + didHandleMap[dids[i]] = resolves[i] 84 + } 85 + return didHandleMap 86 + }, 87 + } 88 + }
+4
src/ident/types.ts
··· 1 + export interface Resolver { 2 + resolveDidToHandle(did: string): Promise<string> 3 + resolveDidsToHandles(dids: string[]): Promise<Record<string, string>> 4 + }
+6 -6
src/lexicon/index.ts
··· 16 16 17 17 export class Server { 18 18 xrpc: XrpcServer 19 - example: ExampleNS 19 + com: ComNS 20 20 21 21 constructor(options?: XrpcOptions) { 22 22 this.xrpc = createXrpcServer(schemas, options) 23 - this.example = new ExampleNS(this) 23 + this.com = new ComNS(this) 24 24 } 25 25 } 26 26 27 - export class ExampleNS { 27 + export class ComNS { 28 28 _server: Server 29 - lexicon: ExampleLexiconNS 29 + example: ComExampleNS 30 30 31 31 constructor(server: Server) { 32 32 this._server = server 33 - this.lexicon = new ExampleLexiconNS(server) 33 + this.example = new ComExampleNS(server) 34 34 } 35 35 } 36 36 37 - export class ExampleLexiconNS { 37 + export class ComExampleNS { 38 38 _server: Server 39 39 40 40 constructor(server: Server) {
+6 -3
src/lexicon/lexicons.ts
··· 4 4 import { LexiconDoc, Lexicons } from '@atproto/lexicon' 5 5 6 6 export const schemaDict = { 7 - ExampleLexiconStatus: { 7 + ComExampleStatus: { 8 8 lexicon: 1, 9 - id: 'example.lexicon.status', 9 + id: 'com.example.status', 10 10 defs: { 11 11 main: { 12 12 type: 'record', ··· 17 17 properties: { 18 18 status: { 19 19 type: 'string', 20 + minLength: 1, 21 + maxGraphemes: 1, 22 + maxLength: 32, 20 23 }, 21 24 updatedAt: { 22 25 type: 'string', ··· 30 33 } 31 34 export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] 32 35 export const lexicons: Lexicons = new Lexicons(schemas) 33 - export const ids = { ExampleLexiconStatus: 'example.lexicon.status' } 36 + export const ids = { ComExampleStatus: 'com.example.status' }
+2 -3
src/lexicon/types/example/lexicon/status.ts src/lexicon/types/com/example/status.ts
··· 16 16 return ( 17 17 isObj(v) && 18 18 hasProp(v, '$type') && 19 - (v.$type === 'example.lexicon.status#main' || 20 - v.$type === 'example.lexicon.status') 19 + (v.$type === 'com.example.status#main' || v.$type === 'com.example.status') 21 20 ) 22 21 } 23 22 24 23 export function validateRecord(v: unknown): ValidationResult { 25 - return lexicons.validate('example.lexicon.status#main', v) 24 + return lexicons.validate('com.example.status#main', v) 26 25 }
+97 -28
src/pages/home.ts
··· 1 1 import { AtUri } from '@atproto/syntax' 2 - import type { Post } from '#/db/schema' 2 + import type { Status } from '#/db/schema' 3 3 import { html } from '../view' 4 4 import { shell } from './shell' 5 5 6 - type Props = { posts: Post[]; profile?: { displayName?: string; handle: string } } 6 + const STATUS_OPTIONS = [ 7 + '👍', 8 + '👎', 9 + '💙', 10 + '🥹', 11 + '😧', 12 + '😤', 13 + '🙃', 14 + '😉', 15 + '😎', 16 + '🤓', 17 + '🤨', 18 + '🥳', 19 + '😭', 20 + '😤', 21 + '🤯', 22 + '🫡', 23 + '💀', 24 + '✊', 25 + '🤘', 26 + '👀', 27 + '🧠', 28 + '👩‍💻', 29 + '🧑‍💻', 30 + '🥷', 31 + '🧌', 32 + '🦋', 33 + '🚀', 34 + ] 35 + 36 + type Props = { 37 + statuses: Status[] 38 + didHandleMap: Record<string, string> 39 + profile?: { displayName?: string; handle: string } 40 + } 7 41 8 42 export function home(props: Props) { 9 43 return shell({ ··· 12 46 }) 13 47 } 14 48 15 - function content({ posts, profile }: Props) { 49 + function content({ statuses, didHandleMap, profile }: Props) { 16 50 return html`<div id="root"> 17 - <h1>Welcome to the Atmosphere</h1> 18 - ${ 19 - profile 20 - ? html`<form action="/logout" method="post"> 21 - <p> 22 - Hi, <b>${profile.displayName || profile.handle}</b>. It's pretty special here. 23 - <button type="submit">Log out.</button> 24 - </p> 25 - </form>` 26 - : html`<p> 27 - It's pretty special here. 28 - <a href="/login">Log in.</a> 29 - </p>` 30 - } 31 - <ul> 32 - ${posts.map((post) => { 33 - return html`<li> 34 - <a href="${toBskyLink(post.uri)}" target="_blank">🔗</a> 35 - ${post.text} 36 - </li>` 51 + <div class="error"></div> 52 + <div id="header"> 53 + <h1>Statusphere</h1> 54 + <p>Set your status on the Atmosphere.</p> 55 + </div> 56 + <div class="container"> 57 + <div class="card"> 58 + ${profile 59 + ? html`<form action="/logout" method="post" class="session-form"> 60 + <div> 61 + Hi, <strong>${profile.displayName || profile.handle}</strong>. 62 + what's your status today? 63 + </div> 64 + <div> 65 + <button type="submit">Log out</button> 66 + </div> 67 + </form>` 68 + : html`<div class="session-form"> 69 + <div><a href="/login">Log in</a> to set your status!</div> 70 + <div> 71 + <a href="/login" class="button">Log in</a> 72 + </div> 73 + </div>`} 74 + </div> 75 + <div class="status-options"> 76 + ${STATUS_OPTIONS.map( 77 + (status) => 78 + html`<div 79 + class="status-option" 80 + data-value="${status}" 81 + data-authed=${profile ? '1' : '0'} 82 + > 83 + ${status} 84 + </div>` 85 + )} 86 + </div> 87 + ${statuses.map((status, i) => { 88 + const handle = didHandleMap[status.authorDid] || status.authorDid 89 + return html` 90 + <div class=${i === 0 ? 'status-line no-line' : 'status-line'}> 91 + <div> 92 + <div class="status">${status.status}</div> 93 + </div> 94 + <div class="desc"> 95 + <a class="author" href=${toBskyLink(handle)}>@${handle}</a> 96 + is feeling ${status.status} on ${ts(status)} 97 + </div> 98 + </div> 99 + ` 37 100 })} 38 - </ul> 39 - <a href="/">Give me more</a> 101 + </div> 102 + <script src="/public/home.js"></script> 40 103 </div>` 41 104 } 42 105 43 - function toBskyLink(uriStr: string) { 44 - const uri = new AtUri(uriStr) 45 - return `https://bsky.app/profile/${uri.host}/post/${uri.rkey}` 106 + function toBskyLink(did: string) { 107 + return `https://bsky.app/profile/${did}` 108 + } 109 + 110 + function ts(status: Status) { 111 + const indexedAt = new Date(status.indexedAt) 112 + const updatedAt = new Date(status.updatedAt) 113 + if (updatedAt > indexedAt) return updatedAt.toDateString() 114 + return indexedAt.toDateString() 46 115 }
+16 -5
src/pages/login.ts
··· 12 12 13 13 function content({ error }: Props) { 14 14 return html`<div id="root"> 15 - <form action="/login" method="post"> 16 - <input type="text" name="handle" placeholder="handle" required /> 17 - <button type="submit">Log in.</button> 18 - ${error ? html`<p>Error: <i>${error}</i></p>` : undefined} 19 - </form> 15 + <div id="header"> 16 + <h1>Statusphere</h1> 17 + <p>Set your status on the Atmosphere.</p> 18 + </div> 19 + <div class="container"> 20 + <form action="/login" method="post" class="login-form"> 21 + <input 22 + type="text" 23 + name="handle" 24 + placeholder="Enter your handle (eg alice.bsky.social)" 25 + required 26 + /> 27 + <button type="submit">Log in</button> 28 + ${error ? html`<p>Error: <i>${error}</i></p>` : undefined} 29 + </form> 30 + </div> 20 31 </div>` 21 32 }
+1 -1
src/pages/shell.ts
··· 4 4 return html`<html> 5 5 <head> 6 6 <title>${title}</title> 7 - <link rel="stylesheet" href="/public/styles.css"> 7 + <link rel="stylesheet" href="/public/styles.css" /> 8 8 </head> 9 9 <body> 10 10 ${content}
+32
src/public/home.js
··· 1 + Array.from(document.querySelectorAll('.status-option'), (el) => { 2 + el.addEventListener('click', async (ev) => { 3 + setError('') 4 + 5 + if (el.dataset.authed !== '1') { 6 + window.location = '/login' 7 + return 8 + } 9 + 10 + const res = await fetch('/status', { 11 + method: 'POST', 12 + headers: { 'content-type': 'application/json' }, 13 + body: JSON.stringify({ status: el.dataset.value }), 14 + }) 15 + const body = await res.json() 16 + if (body?.error) { 17 + setError(body.error) 18 + } else { 19 + location.reload() 20 + } 21 + }) 22 + }) 23 + 24 + function setError(str) { 25 + const errMsg = document.querySelector('.error') 26 + if (str) { 27 + errMsg.classList.add('visible') 28 + errMsg.textContent = str 29 + } else { 30 + errMsg.classList.remove('visible') 31 + } 32 + }
+161 -3
src/public/styles.css
··· 1 1 body { 2 2 font-family: Arial, Helvetica, sans-serif; 3 - } 4 3 5 - #root { 6 - padding: 20px; 4 + --border-color: #ddd; 5 + --gray-100: #fafafa; 6 + --gray-500: #666; 7 + --gray-700: #333; 8 + --primary-400: #2e8fff; 9 + --primary-500: #0078ff; 10 + --primary-600: #0066db; 11 + --error-500: #f00; 12 + --error-100: #fee; 7 13 } 8 14 9 15 /* ··· 33 39 #root, #__next { 34 40 isolation: isolate; 35 41 } 42 + 43 + /* 44 + Common components 45 + */ 46 + button, .button { 47 + display: inline-block; 48 + border: 0; 49 + background-color: var(--primary-500); 50 + border-radius: 50px; 51 + color: #fff; 52 + padding: 2px 10px; 53 + cursor: pointer; 54 + text-decoration: none; 55 + } 56 + button:hover, .button:hover { 57 + background: var(--primary-400); 58 + } 59 + 60 + /* 61 + Custom components 62 + */ 63 + .error { 64 + background-color: var(--error-100); 65 + color: var(--error-500); 66 + text-align: center; 67 + padding: 1rem; 68 + display: none; 69 + } 70 + .error.visible { 71 + display: block; 72 + } 73 + 74 + #header { 75 + background-color: #fff; 76 + text-align: center; 77 + padding: 0.5rem 0 1.5rem; 78 + } 79 + 80 + #header h1 { 81 + font-size: 5rem; 82 + } 83 + 84 + .container { 85 + display: flex; 86 + flex-direction: column; 87 + gap: 4px; 88 + margin: 0 auto; 89 + max-width: 600px; 90 + padding: 20px; 91 + } 92 + 93 + .card { 94 + /* border: 1px solid var(--border-color); */ 95 + border-radius: 6px; 96 + padding: 10px 16px; 97 + background-color: #fff; 98 + } 99 + .card > :first-child { 100 + margin-top: 0; 101 + } 102 + .card > :last-child { 103 + margin-bottom: 0; 104 + } 105 + 106 + .session-form { 107 + display: flex; 108 + flex-direction: row; 109 + align-items: center; 110 + justify-content: space-between; 111 + } 112 + 113 + .login-form { 114 + display: flex; 115 + flex-direction: row; 116 + gap: 6px; 117 + border: 1px solid var(--border-color); 118 + border-radius: 6px; 119 + padding: 10px 16px; 120 + background-color: #fff; 121 + } 122 + 123 + .login-form input { 124 + flex: 1; 125 + border: 0; 126 + } 127 + 128 + .status-options { 129 + display: flex; 130 + flex-direction: row; 131 + flex-wrap: wrap; 132 + gap: 8px; 133 + margin: 10px 0; 134 + } 135 + 136 + .status-option { 137 + font-size: 2rem; 138 + width: 3rem; 139 + height: 3rem; 140 + background-color: #fff; 141 + border: 1px solid var(--border-color); 142 + border-radius: 3rem; 143 + text-align: center; 144 + box-shadow: 0 1px 4px #0001; 145 + cursor: pointer; 146 + } 147 + 148 + .status-option:hover { 149 + background-color: var(--gray-100); 150 + } 151 + 152 + .status-line { 153 + display: flex; 154 + flex-direction: row; 155 + align-items: center; 156 + gap: 10px; 157 + position: relative; 158 + margin-top: 15px; 159 + } 160 + 161 + .status-line:not(.no-line)::before { 162 + content: ''; 163 + position: absolute; 164 + width: 2px; 165 + background-color: var(--border-color); 166 + left: 1.45rem; 167 + bottom: calc(100% + 2px); 168 + height: 15px; 169 + } 170 + 171 + .status-line .status { 172 + font-size: 2rem; 173 + background-color: #fff; 174 + width: 3rem; 175 + height: 3rem; 176 + border-radius: 1.5rem; 177 + text-align: center; 178 + border: 1px solid var(--border-color); 179 + } 180 + 181 + .status-line .desc { 182 + color: var(--gray-500); 183 + } 184 + 185 + .status-line .author { 186 + color: var(--gray-700); 187 + font-weight: 600; 188 + text-decoration: none; 189 + } 190 + 191 + .status-line .author:hover { 192 + text-decoration: underline; 193 + }
+91 -12
src/routes/index.ts
··· 8 8 import { login } from '#/pages/login' 9 9 import { page } from '#/view' 10 10 import { handler } from './util' 11 + import * as Status from '#/lexicon/types/com/example/status' 11 12 12 13 export const createRouter = (ctx: AppContext) => { 13 14 const router = express.Router() ··· 18 19 '/client-metadata.json', 19 20 handler((_req, res) => { 20 21 return res.json(ctx.oauthClient.clientMetadata) 21 - }), 22 + }) 22 23 ) 23 24 24 25 router.get( ··· 33 34 return res.redirect('/?error') 34 35 } 35 36 return res.redirect('/') 36 - }), 37 + }) 37 38 ) 38 39 39 40 router.get( 40 41 '/login', 41 42 handler(async (_req, res) => { 42 43 return res.type('html').send(page(login({}))) 43 - }), 44 + }) 44 45 ) 45 46 46 47 router.post( ··· 58 59 return res.type('html').send( 59 60 page( 60 61 login({ 61 - error: err instanceof OAuthResolverError ? err.message : "couldn't initiate login", 62 - }), 63 - ), 62 + error: 63 + err instanceof OAuthResolverError 64 + ? err.message 65 + : "couldn't initiate login", 66 + }) 67 + ) 64 68 ) 65 69 } 66 - }), 70 + }) 67 71 ) 68 72 69 73 router.post( ··· 71 75 handler(async (req, res) => { 72 76 await destroySession(req, res) 73 77 return res.redirect('/') 74 - }), 78 + }) 75 79 ) 76 80 77 81 router.get( ··· 85 89 await destroySession(req, res) 86 90 return null 87 91 })) 88 - const posts = await ctx.db.selectFrom('post').selectAll().orderBy('indexedAt', 'desc').limit(10).execute() 92 + const statuses = await ctx.db 93 + .selectFrom('status') 94 + .selectAll() 95 + .orderBy('indexedAt', 'desc') 96 + .limit(10) 97 + .execute() 98 + const didHandleMap = await ctx.resolver.resolveDidsToHandles( 99 + statuses.map((s) => s.authorDid) 100 + ) 89 101 if (!agent) { 90 - return res.type('html').send(page(home({ posts }))) 102 + return res.type('html').send(page(home({ statuses, didHandleMap }))) 91 103 } 92 104 const { data: profile } = await agent.getProfile({ actor: session.did }) 93 - return res.type('html').send(page(home({ posts, profile }))) 94 - }), 105 + return res 106 + .type('html') 107 + .send(page(home({ statuses, didHandleMap, profile }))) 108 + }) 109 + ) 110 + 111 + router.post( 112 + '/status', 113 + handler(async (req, res) => { 114 + const session = await getSession(req, res) 115 + const agent = 116 + session && 117 + (await ctx.oauthClient.restore(session.did).catch(async (err) => { 118 + ctx.logger.warn({ err }, 'oauth restore failed') 119 + await destroySession(req, res) 120 + return null 121 + })) 122 + if (!agent) { 123 + return res.status(401).json({ error: 'Session required' }) 124 + } 125 + 126 + const record = { 127 + $type: 'com.example.status', 128 + status: req.body?.status, 129 + updatedAt: new Date().toISOString(), 130 + } 131 + if (!Status.validateRecord(record).success) { 132 + return res.status(400).json({ error: 'Invalid status' }) 133 + } 134 + 135 + try { 136 + await agent.com.atproto.repo.putRecord({ 137 + repo: agent.accountDid, 138 + collection: 'com.example.status', 139 + rkey: 'self', 140 + record, 141 + validate: false, 142 + }) 143 + } catch (err) { 144 + ctx.logger.warn({ err }, 'failed to write record') 145 + return res.status(500).json({ error: 'Failed to write record' }) 146 + } 147 + 148 + try { 149 + await ctx.db 150 + .insertInto('status') 151 + .values({ 152 + authorDid: agent.accountDid, 153 + status: record.status, 154 + updatedAt: record.updatedAt, 155 + indexedAt: new Date().toISOString(), 156 + }) 157 + .onConflict((oc) => 158 + oc.column('authorDid').doUpdateSet({ 159 + status: record.status, 160 + updatedAt: record.updatedAt, 161 + indexedAt: new Date().toISOString(), 162 + }) 163 + ) 164 + .execute() 165 + } catch (err) { 166 + ctx.logger.warn( 167 + { err }, 168 + 'failed to update computed view; ignoring as it should be caught by the firehose' 169 + ) 170 + } 171 + 172 + res.status(200).json({}) 173 + }) 95 174 ) 96 175 97 176 return router
+9 -6
src/server.ts
··· 11 11 import errorHandler from '#/middleware/errorHandler' 12 12 import requestLogger from '#/middleware/requestLogger' 13 13 import { createRouter } from '#/routes' 14 - import { createClient } from './auth/client' 15 - import type { AppContext } from './config' 14 + import { createClient } from '#/auth/client' 15 + import { createResolver } from '#/ident/resolver' 16 + import type { AppContext } from '#/config' 16 17 17 18 export class Server { 18 19 constructor( 19 20 public app: express.Application, 20 21 public server: http.Server, 21 - public ctx: AppContext, 22 + public ctx: AppContext 22 23 ) {} 23 24 24 25 static async create() { 25 - const { NODE_ENV, HOST, PORT } = env 26 + const { NODE_ENV, HOST, PORT, DB_PATH } = env 26 27 27 28 const logger = pino({ name: 'server start' }) 28 - const db = createDb(':memory:') 29 + const db = createDb(DB_PATH) 29 30 await migrateToLatest(db) 30 31 const ingester = new Ingester(db) 31 32 const oauthClient = await createClient(db) 33 + const resolver = await createResolver(db) 32 34 ingester.start() 33 35 const ctx = { 34 36 db, 35 37 ingester, 36 38 logger, 37 39 oauthClient, 40 + resolver, 38 41 } 39 42 40 43 const app: Express = express() ··· 57 60 formAction: null, 58 61 }, 59 62 }, 60 - }), 63 + }) 61 64 ) 62 65 63 66 // Request logging