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

add jetstream

Changed files
+272 -29
lexicons
xyz
statusphere
packages
appview
src
client
lexicon
src
types
xyz
statusphere
-1
lexicons/xyz/statusphere/getStatuses.json
··· 22 22 "type": "object", 23 23 "required": ["statuses"], 24 24 "properties": { 25 - "cursor": { "type": "string" }, 26 25 "statuses": { 27 26 "type": "array", 28 27 "items": {
+4 -3
packages/appview/src/context.ts
··· 2 2 import { Firehose } from '@atproto/sync' 3 3 import pino from 'pino' 4 4 5 - import { Database } from './db' 6 - import { BidirectionalResolver } from './id-resolver' 5 + import { Database } from '#/db' 6 + import { BidirectionalResolver } from '#/id-resolver' 7 + import { Jetstream } from '#/ingestors' 7 8 8 9 // Application state passed to the router and elsewhere 9 10 export type AppContext = { 10 11 db: Database 11 - ingester: Firehose 12 + ingester: Firehose | Jetstream<any> 12 13 logger: pino.Logger 13 14 oauthClient: OAuthClient 14 15 resolver: BidirectionalResolver
+15
packages/appview/src/db.ts
··· 53 53 }, 54 54 } 55 55 56 + migrations['003'] = { 57 + async up(db: Kysely<unknown>) {}, 58 + async down(_db: Kysely<unknown>) {}, 59 + } 60 + 56 61 migrations['002'] = { 57 62 async up(db: Kysely<unknown>) { 58 63 await db.schema 59 64 .createTable('cursor') 60 65 .addColumn('id', 'integer', (col) => col.primaryKey()) 61 66 .addColumn('seq', 'integer', (col) => col.notNull()) 67 + .execute() 68 + 69 + // Insert initial cursor values: 70 + // id=1 is for firehose, id=2 is for jetstream 71 + await db 72 + .insertInto('cursor' as never) 73 + .values([ 74 + { id: 1, seq: 0 }, 75 + { id: 2, seq: 0 }, 76 + ]) 62 77 .execute() 63 78 }, 64 79 async down(db: Kysely<unknown>) {
+4 -3
packages/appview/src/index.ts
··· 14 14 import { createDb, migrateToLatest } from '#/db' 15 15 import * as error from '#/error' 16 16 import { createBidirectionalResolver, createIdResolver } from '#/id-resolver' 17 - import { createIngester } from '#/ingester' 17 + import { createFirehoseIngester, createJetstreamIngester } from '#/ingestors' 18 18 import { createServer } from '#/lexicons' 19 19 import { env } from '#/lib/env' 20 20 ··· 36 36 // Create the atproto utilities 37 37 const oauthClient = await createClient(db) 38 38 const baseIdResolver = createIdResolver() 39 - const ingester = await createIngester(db, baseIdResolver) 39 + const ingester = await createJetstreamIngester(db) 40 + // Alternative: const ingester = await createFirehoseIngester(db, baseIdResolver) 40 41 const resolver = createBidirectionalResolver(baseIdResolver) 41 42 const ctx = { 42 43 db, ··· 103 104 }) 104 105 } 105 106 } else { 106 - server.xrpc.router.set('trust proxy', true) 107 + app.set('trust proxy', true) 107 108 } 108 109 109 110 // Use the port from env (should be 3001 for the API server)
+4 -1
packages/appview/src/ingester.ts packages/appview/src/ingestors/firehose.ts
··· 5 5 6 6 import type { Database } from '#/db' 7 7 8 - export async function createIngester(db: Database, idResolver: IdResolver) { 8 + export async function createFirehoseIngester( 9 + db: Database, 10 + idResolver: IdResolver, 11 + ) { 9 12 const logger = pino({ name: 'firehose ingestion' }) 10 13 11 14 const cursor = await db
+2
packages/appview/src/ingestors/index.ts
··· 1 + export * from './jetstream' 2 + export * from './firehose'
+213
packages/appview/src/ingestors/jetstream.ts
··· 1 + import { XyzStatusphereStatus } from '@statusphere/lexicon' 2 + import pino from 'pino' 3 + import WebSocket from 'ws' 4 + 5 + import type { Database } from '#/db' 6 + 7 + export async function createJetstreamIngester(db: Database) { 8 + const logger = pino({ name: 'jetstream ingestion' }) 9 + 10 + const cursor = await db 11 + .selectFrom('cursor') 12 + .where('id', '=', 2) 13 + .select('seq') 14 + .executeTakeFirst() 15 + 16 + logger.info(`start cursor: ${cursor?.seq}`) 17 + 18 + // For throttling cursor writes 19 + let lastCursorWrite = 0 20 + 21 + return new Jetstream<XyzStatusphereStatus.Record>({ 22 + logger, 23 + cursor: cursor?.seq || undefined, 24 + setCursor: async (seq) => { 25 + const now = Date.now() 26 + 27 + if (now - lastCursorWrite >= 30000) { 28 + lastCursorWrite = now 29 + logger.info(`writing cursor: ${seq}`) 30 + await db 31 + .updateTable('cursor') 32 + .set({ seq }) 33 + .where('id', '=', 2) 34 + .execute() 35 + } 36 + }, 37 + handleEvent: async (evt) => { 38 + // ignore account and identity events 39 + if ( 40 + evt.kind !== 'commit' || 41 + evt.commit.collection !== 'xyz.statusphere.status' 42 + ) 43 + return 44 + 45 + const now = new Date() 46 + const uri = `at://${evt.did}/${evt.commit.collection}/${evt.commit.rkey}` 47 + 48 + if ( 49 + (evt.commit.operation === 'create' || 50 + evt.commit.operation === 'update') && 51 + XyzStatusphereStatus.isRecord(evt.commit.record) 52 + ) { 53 + const validatedRecord = XyzStatusphereStatus.validateRecord( 54 + evt.commit.record, 55 + ) 56 + if (!validatedRecord.success) return 57 + 58 + // Store the status in our SQLite 59 + await db 60 + .insertInto('status') 61 + .values({ 62 + uri, 63 + authorDid: evt.did, 64 + status: validatedRecord.value.status, 65 + createdAt: validatedRecord.value.createdAt, 66 + indexedAt: now.toISOString(), 67 + }) 68 + .onConflict((oc) => 69 + oc.column('uri').doUpdateSet({ 70 + status: validatedRecord.value.status, 71 + indexedAt: now.toISOString(), 72 + }), 73 + ) 74 + .execute() 75 + } else if (evt.commit.operation === 'delete') { 76 + // Remove the status from our SQLite 77 + await db.deleteFrom('status').where('uri', '=', uri).execute() 78 + } 79 + }, 80 + onError: (err) => { 81 + logger.error({ err }, 'error during jetstream ingestion') 82 + }, 83 + wantedCollections: ['xyz.statusphere.status'], 84 + }) 85 + } 86 + 87 + export class Jetstream<T> { 88 + private logger: pino.Logger 89 + private handleEvent: (evt: JetstreamEvent<T>) => Promise<void> 90 + private onError: (err: unknown) => void 91 + private setCursor?: (seq: number) => Promise<void> 92 + private cursor?: number 93 + private ws?: WebSocket 94 + private isStarted = false 95 + private wantedCollections: string[] 96 + 97 + constructor({ 98 + logger, 99 + cursor, 100 + setCursor, 101 + handleEvent, 102 + onError, 103 + wantedCollections, 104 + }: { 105 + logger: pino.Logger 106 + cursor?: number 107 + setCursor?: (seq: number) => Promise<void> 108 + handleEvent: (evt: any) => Promise<void> 109 + onError: (err: any) => void 110 + wantedCollections: string[] 111 + }) { 112 + this.logger = logger 113 + this.cursor = cursor 114 + this.setCursor = setCursor 115 + this.handleEvent = handleEvent 116 + this.onError = onError 117 + this.wantedCollections = wantedCollections 118 + } 119 + 120 + constructUrlWithQuery = (): string => { 121 + const params = new URLSearchParams() 122 + params.append('wantedCollections', this.wantedCollections.join(',')) 123 + if (this.cursor !== undefined) { 124 + params.append('cursor', this.cursor.toString()) 125 + } 126 + return `wss://jetstream.mozzius.dev/subscribe?${params.toString()}` 127 + } 128 + 129 + start() { 130 + if (this.isStarted) return 131 + this.isStarted = true 132 + this.ws = new WebSocket(this.constructUrlWithQuery()) 133 + 134 + this.ws.on('open', () => { 135 + this.logger.info('Jetstream connection opened.') 136 + }) 137 + 138 + this.ws.on('message', async (data) => { 139 + try { 140 + const event: JetstreamEvent<T> = JSON.parse(data.toString()) 141 + 142 + // Update cursor if provided 143 + if (event.time_us !== undefined && this.setCursor) { 144 + await this.setCursor(event.time_us) 145 + } 146 + 147 + await this.handleEvent(event) 148 + } catch (err) { 149 + this.onError(err) 150 + } 151 + }) 152 + 153 + this.ws.on('error', (err) => { 154 + this.onError(err) 155 + }) 156 + 157 + this.ws.on('close', (code, reason) => { 158 + this.logger.error(`Jetstream closed. Code: ${code}, Reason: ${reason}`) 159 + this.isStarted = false 160 + }) 161 + } 162 + 163 + destroy() { 164 + if (this.ws) { 165 + this.ws.close() 166 + this.isStarted = false 167 + } 168 + } 169 + } 170 + 171 + type JetstreamEvent<T> = { 172 + did: string 173 + time_us: number 174 + } & (CommitEvent<T> | AccountEvent | IdentityEvent) 175 + 176 + type CommitEvent<T> = { 177 + kind: 'commit' 178 + commit: 179 + | { 180 + operation: 'create' | 'update' 181 + record: T 182 + rev: string 183 + collection: string 184 + rkey: string 185 + cid: string 186 + } 187 + | { 188 + operation: 'delete' 189 + rev: string 190 + collection: string 191 + rkey: string 192 + } 193 + } 194 + 195 + type IdentityEvent = { 196 + kind: 'identity' 197 + identity: { 198 + did: string 199 + handle: string 200 + seq: number 201 + time: string 202 + } 203 + } 204 + 205 + type AccountEvent = { 206 + kind: 'account' 207 + account: { 208 + active: boolean 209 + did: string 210 + seq: number 211 + time: string 212 + } 213 + }
-3
packages/appview/src/lexicons/lexicons.ts
··· 79 79 type: 'object', 80 80 required: ['statuses'], 81 81 properties: { 82 - cursor: { 83 - type: 'string', 84 - }, 85 82 statuses: { 86 83 type: 'array', 87 84 items: {
-1
packages/appview/src/lexicons/types/xyz/statusphere/getStatuses.ts
··· 21 21 export type InputSchema = undefined 22 22 23 23 export interface OutputSchema { 24 - cursor?: string 25 24 statuses: XyzStatusphereDefs.StatusView[] 26 25 } 27 26
+1 -1
packages/client/src/components/StatusForm.tsx
··· 5 5 import useAuth from '#/hooks/useAuth' 6 6 import api from '#/services/api' 7 7 8 - const STATUS_OPTIONS = [ 8 + export const STATUS_OPTIONS = [ 9 9 '👍', 10 10 '👎', 11 11 '💙',
+11 -2
packages/client/src/components/StatusList.tsx
··· 2 2 import { useQuery } from '@tanstack/react-query' 3 3 4 4 import api from '#/services/api' 5 + import { STATUS_OPTIONS } from './StatusForm' 5 6 6 7 const StatusList = () => { 7 8 // Use React Query to fetch and cache statuses ··· 23 24 24 25 // Destructure data 25 26 const statuses = data?.statuses || [] 27 + 28 + // Get a random emoji from the STATUS_OPTIONS array 29 + const randomEmoji = STATUS_OPTIONS[Math.floor(Math.random() * STATUS_OPTIONS.length)] 26 30 27 31 if (isPending && !data) { 28 32 return ( 29 - <div className="py-4 text-center text-gray-500 dark:text-gray-400"> 30 - Loading statuses... 33 + <div className="py-8 text-center"> 34 + <div className="text-5xl mb-2 animate-pulse inline-block"> 35 + {randomEmoji} 36 + </div> 37 + <div className="text-gray-500 dark:text-gray-400"> 38 + Loading statuses... 39 + </div> 31 40 </div> 32 41 ) 33 42 }
+7 -10
packages/client/src/pages/HomePage.tsx
··· 1 1 import Header from '#/components/Header' 2 - import StatusForm from '#/components/StatusForm' 2 + import StatusForm, { STATUS_OPTIONS } from '#/components/StatusForm' 3 3 import StatusList from '#/components/StatusList' 4 4 import { useAuth } from '#/hooks/useAuth' 5 5 6 6 const HomePage = () => { 7 7 const { user, loading, error } = useAuth() 8 8 9 + // Get a random emoji from the STATUS_OPTIONS array 10 + const randomEmoji = 11 + STATUS_OPTIONS[Math.floor(Math.random() * STATUS_OPTIONS.length)] 12 + 9 13 if (loading) { 10 14 return ( 11 - <div className="flex justify-center items-center py-16"> 12 - <div className="text-center p-6"> 13 - <h2 className="text-2xl font-semibold mb-2 text-gray-800 dark:text-gray-200"> 14 - Loading Statusphere... 15 - </h2> 16 - <p className="text-gray-600 dark:text-gray-400"> 17 - Setting up your experience 18 - </p> 19 - </div> 15 + <div className="flex justify-center items-center h-[80vh]"> 16 + <div className="text-9xl animate-pulse">{randomEmoji}</div> 20 17 </div> 21 18 ) 22 19 }
+11
packages/client/vite.config.ts
··· 21 21 '^/(xrpc|oauth|client-metadata\.json)/.*': { 22 22 target: 'http://localhost:3001', 23 23 changeOrigin: true, 24 + configure: (proxy, _options) => { 25 + proxy.on('error', (err, _req, _res) => { 26 + console.log('PROXY ERROR', err); 27 + }); 28 + proxy.on('proxyReq', (proxyReq, req, _res) => { 29 + console.log('PROXY REQUEST', req.method, req.url); 30 + }); 31 + proxy.on('proxyRes', (proxyRes, req, _res) => { 32 + console.log('PROXY RESPONSE', req.method, req.url, proxyRes.statusCode); 33 + }); 34 + }, 24 35 }, 25 36 }, 26 37 },
-3
packages/lexicon/src/lexicons.ts
··· 79 79 type: 'object', 80 80 required: ['statuses'], 81 81 properties: { 82 - cursor: { 83 - type: 'string', 84 - }, 85 82 statuses: { 86 83 type: 'array', 87 84 items: {
-1
packages/lexicon/src/types/xyz/statusphere/getStatuses.ts
··· 20 20 export type InputSchema = undefined 21 21 22 22 export interface OutputSchema { 23 - cursor?: string 24 23 statuses: XyzStatusphereDefs.StatusView[] 25 24 } 26 25