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

Add did->handle resolution

+151 -17
+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 }
+4 -3
src/db/migrations.ts
··· 11 11 migrations['001'] = { 12 12 async up(db: Kysely<unknown>) { 13 13 await db.schema 14 - .createTable('user') 14 + .createTable('did_cache') 15 15 .addColumn('did', 'varchar', (col) => col.primaryKey()) 16 - .addColumn('handle', 'varchar', (col) => col.notNull()) 17 - .addColumn('indexedAt', 'varchar', (col) => col.notNull()) 16 + .addColumn('doc', 'varchar', (col) => col.notNull()) 17 + .addColumn('updatedAt', 'varchar', (col) => col.notNull()) 18 18 .execute() 19 19 await db.schema 20 20 .createTable('status') ··· 38 38 await db.schema.dropTable('auth_state').execute() 39 39 await db.schema.dropTable('auth_session').execute() 40 40 await db.schema.dropTable('post').execute() 41 + await db.schema.dropTable('did_cache').execute() 41 42 }, 42 43 }
+4 -4
src/db/schema.ts
··· 1 1 export type DatabaseSchema = { 2 - user: User 2 + did_cache: DidCache 3 3 status: Status 4 4 auth_session: AuthSession 5 5 auth_state: AuthState 6 6 } 7 7 8 - export type User = { 8 + export type DidCache = { 9 9 did: string 10 - handle: string 11 - indexedAt: string 10 + doc: string 11 + updatedAt: string 12 12 } 13 13 14 14 export type Status = {
+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 + }
+4 -4
src/pages/home.ts
··· 35 35 36 36 type Props = { 37 37 statuses: Status[] 38 + didHandleMap: Record<string, string> 38 39 profile?: { displayName?: string; handle: string } 39 40 } 40 41 ··· 45 46 }) 46 47 } 47 48 48 - function content({ statuses, profile }: Props) { 49 + function content({ statuses, didHandleMap, profile }: Props) { 49 50 return html`<div id="root"> 50 51 <div class="error"></div> 51 52 <div id="header"> ··· 119 120 </div> 120 121 </div> 121 122 ${statuses.map((status, i) => { 123 + const handle = didHandleMap[status.authorDid] || status.authorDid 122 124 return html` 123 125 <div class=${i === 0 ? 'status-line no-line' : 'status-line'}> 124 126 <div> 125 127 <div class="status">${status.status}</div> 126 128 </div> 127 129 <div class="desc"> 128 - <a class="author" href=${toBskyLink(status.authorDid)} 129 - >@${status.authorDid}</a 130 - > 130 + <a class="author" href=${toBskyLink(handle)}>@${handle}</a> 131 131 is feeling ${status.status} on ${ts(status)} 132 132 </div> 133 133 </div>
+7 -2
src/routes/index.ts
··· 95 95 .orderBy('indexedAt', 'desc') 96 96 .limit(10) 97 97 .execute() 98 + const didHandleMap = await ctx.resolver.resolveDidsToHandles( 99 + statuses.map((s) => s.authorDid) 100 + ) 98 101 if (!agent) { 99 - return res.type('html').send(page(home({ statuses }))) 102 + return res.type('html').send(page(home({ statuses, didHandleMap }))) 100 103 } 101 104 const { data: profile } = await agent.getProfile({ actor: session.did }) 102 - return res.type('html').send(page(home({ statuses, profile }))) 105 + return res 106 + .type('html') 107 + .send(page(home({ statuses, didHandleMap, profile }))) 103 108 }) 104 109 ) 105 110
+5 -2
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( ··· 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()
+24
yarn.lock
··· 139 139 dependencies: 140 140 zod "^3.23.8" 141 141 142 + "@atproto/identity@^0.4.0": 143 + version "0.4.0" 144 + resolved "https://registry.yarnpkg.com/@atproto/identity/-/identity-0.4.0.tgz#f8a4d450a20606d221c4ec05b856c0ce55f0a3a7" 145 + integrity sha512-KKdVlqBgkFuTUx3KFiiQe0LuK9kopej1bhKm6SHRPEYbSEPFmRZQMY9TAjWJQrvQt8DpQzz6kVGjASFEjd3teQ== 146 + dependencies: 147 + "@atproto/common-web" "^0.3.0" 148 + "@atproto/crypto" "^0.4.0" 149 + axios "^0.27.2" 150 + 142 151 "@atproto/jwk-jose@0.1.2-rc.0": 143 152 version "0.1.2-rc.0" 144 153 resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.2-rc.0.tgz#2fc1e74fc88f9dca807338131ae3fe0994bfee5f" ··· 1105 1114 resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef" 1106 1115 integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw== 1107 1116 1117 + axios@^0.27.2: 1118 + version "0.27.2" 1119 + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" 1120 + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== 1121 + dependencies: 1122 + follow-redirects "^1.14.9" 1123 + form-data "^4.0.0" 1124 + 1108 1125 balanced-match@^1.0.0: 1109 1126 version "1.0.2" 1110 1127 resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" ··· 1834 1851 parseurl "~1.3.3" 1835 1852 statuses "2.0.1" 1836 1853 unpipe "~1.0.0" 1854 + 1855 + follow-redirects@^1.14.9: 1856 + version "1.15.6" 1857 + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" 1858 + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== 1837 1859 1838 1860 foreground-child@^3.1.0: 1839 1861 version "3.3.0" ··· 3110 3132 integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== 3111 3133 3112 3134 "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: 3135 + name string-width-cjs 3113 3136 version "4.2.3" 3114 3137 resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 3115 3138 integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== ··· 3144 3167 safe-buffer "~5.2.0" 3145 3168 3146 3169 "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: 3170 + name strip-ansi-cjs 3147 3171 version "6.0.1" 3148 3172 resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 3149 3173 integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==