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

tweak linting

dholms 05fbd57d b186646a

+423 -490
+6
biome.json
··· 1 1 { 2 2 "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 + "javascript": { 4 + "formatter": { 5 + "semicolons": "asNeeded", 6 + "quoteStyle": "single" 7 + } 8 + }, 3 9 "formatter": { 4 10 "indentStyle": "space", 5 11 "lineWidth": 120
+7 -7
src/config.ts
··· 1 - import type pino from "pino"; 2 - import type { Database } from "#/db"; 3 - import type { Ingester } from "#/firehose/ingester"; 1 + import type pino from 'pino' 2 + import type { Database } from '#/db' 3 + import type { Ingester } from '#/firehose/ingester' 4 4 5 5 export type AppContext = { 6 - db: Database; 7 - ingester: Ingester; 8 - logger: pino.Logger; 9 - }; 6 + db: Database 7 + ingester: Ingester 8 + logger: pino.Logger 9 + }
+11 -11
src/db/index.ts
··· 1 - import SqliteDb from "better-sqlite3"; 2 - import { Kysely, Migrator, SqliteDialect } from "kysely"; 3 - import { migrationProvider } from "./migrations"; 4 - import type { DatabaseSchema } from "./schema"; 1 + import SqliteDb from 'better-sqlite3' 2 + import { Kysely, Migrator, SqliteDialect } from 'kysely' 3 + import { migrationProvider } from './migrations' 4 + import type { DatabaseSchema } from './schema' 5 5 6 6 export const createDb = (location: string): Database => { 7 7 return new Kysely<DatabaseSchema>({ 8 8 dialect: new SqliteDialect({ 9 9 database: new SqliteDb(location), 10 10 }), 11 - }); 12 - }; 11 + }) 12 + } 13 13 14 14 export const migrateToLatest = async (db: Database) => { 15 - const migrator = new Migrator({ db, provider: migrationProvider }); 16 - const { error } = await migrator.migrateToLatest(); 17 - if (error) throw error; 18 - }; 15 + const migrator = new Migrator({ db, provider: migrationProvider }) 16 + const { error } = await migrator.migrateToLatest() 17 + if (error) throw error 18 + } 19 19 20 - export type Database = Kysely<DatabaseSchema>; 20 + export type Database = Kysely<DatabaseSchema>
+12 -12
src/db/migrations.ts
··· 1 - import type { Kysely, Migration, MigrationProvider } from "kysely"; 1 + import type { Kysely, Migration, MigrationProvider } from 'kysely' 2 2 3 - const migrations: Record<string, Migration> = {}; 3 + const migrations: Record<string, Migration> = {} 4 4 5 5 export const migrationProvider: MigrationProvider = { 6 6 async getMigrations() { 7 - return migrations; 7 + return migrations 8 8 }, 9 - }; 9 + } 10 10 11 - migrations["001"] = { 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()) 17 - .addColumn("indexedAt", "varchar", (col) => col.notNull()) 18 - .execute(); 14 + .createTable('post') 15 + .addColumn('uri', 'varchar', (col) => col.primaryKey()) 16 + .addColumn('text', 'varchar', (col) => col.notNull()) 17 + .addColumn('indexedAt', 'varchar', (col) => col.notNull()) 18 + .execute() 19 19 }, 20 20 async down(db: Kysely<unknown>) { 21 - await db.schema.dropTable("post").execute(); 21 + await db.schema.dropTable('post').execute() 22 22 }, 23 - }; 23 + }
+6 -6
src/db/schema.ts
··· 1 1 export type DatabaseSchema = { 2 - post: Post; 3 - }; 2 + post: Post 3 + } 4 4 5 5 export type Post = { 6 - uri: string; 7 - text: string; 8 - indexedAt: string; 9 - }; 6 + uri: string 7 + text: string 8 + indexedAt: string 9 + }
+7 -7
src/env.ts
··· 1 - import dotenv from "dotenv"; 2 - import { cleanEnv, host, num, port, str, testOnly } from "envalid"; 1 + import dotenv from 'dotenv' 2 + import { cleanEnv, host, num, port, str, testOnly } from 'envalid' 3 3 4 - dotenv.config(); 4 + dotenv.config() 5 5 6 6 export const env = cleanEnv(process.env, { 7 - NODE_ENV: str({ devDefault: testOnly("test"), choices: ["development", "production", "test"] }), 8 - HOST: host({ devDefault: testOnly("localhost") }), 7 + NODE_ENV: str({ devDefault: testOnly('test'), choices: ['development', 'production', 'test'] }), 8 + HOST: host({ devDefault: testOnly('localhost') }), 9 9 PORT: port({ devDefault: testOnly(3000) }), 10 - CORS_ORIGIN: str({ devDefault: testOnly("http://localhost:3000") }), 10 + CORS_ORIGIN: str({ devDefault: testOnly('http://localhost:3000') }), 11 11 COMMON_RATE_LIMIT_MAX_REQUESTS: num({ devDefault: testOnly(1000) }), 12 12 COMMON_RATE_LIMIT_WINDOW_MS: num({ devDefault: testOnly(1000) }), 13 - }); 13 + })
+88 -94
src/firehose/firehose.ts
··· 1 - import type { RepoRecord } from "@atproto/lexicon"; 2 - import { cborToLexRecord, readCar } from "@atproto/repo"; 3 - import { AtUri } from "@atproto/syntax"; 4 - import { Subscription } from "@atproto/xrpc-server"; 5 - import type { CID } from "multiformats/cid"; 1 + import type { RepoRecord } from '@atproto/lexicon' 2 + import { cborToLexRecord, readCar } from '@atproto/repo' 3 + import { AtUri } from '@atproto/syntax' 4 + import { Subscription } from '@atproto/xrpc-server' 5 + import type { CID } from 'multiformats/cid' 6 6 import { 7 7 type Account, 8 8 type Commit, ··· 12 12 isCommit, 13 13 isIdentity, 14 14 isValidRepoEvent, 15 - } from "./lexicons"; 15 + } from './lexicons' 16 16 17 17 type Opts = { 18 - service?: string; 19 - getCursor?: () => Promise<number | undefined>; 20 - setCursor?: (cursor: number) => Promise<void>; 21 - subscriptionReconnectDelay?: number; 22 - filterCollections?: string[]; 23 - excludeIdentity?: boolean; 24 - excludeAccount?: boolean; 25 - excludeCommit?: boolean; 26 - }; 18 + service?: string 19 + getCursor?: () => Promise<number | undefined> 20 + setCursor?: (cursor: number) => Promise<void> 21 + subscriptionReconnectDelay?: number 22 + filterCollections?: string[] 23 + excludeIdentity?: boolean 24 + excludeAccount?: boolean 25 + excludeCommit?: boolean 26 + } 27 27 28 28 export class Firehose { 29 - public sub: Subscription<RepoEvent>; 30 - private abortController: AbortController; 29 + public sub: Subscription<RepoEvent> 30 + private abortController: AbortController 31 31 32 32 constructor(public opts: Opts) { 33 - this.abortController = new AbortController(); 33 + this.abortController = new AbortController() 34 34 this.sub = new Subscription({ 35 - service: opts.service ?? "https://bsky.network", 36 - method: "com.atproto.sync.subscribeRepos", 35 + service: opts.service ?? 'https://bsky.network', 36 + method: 'com.atproto.sync.subscribeRepos', 37 37 signal: this.abortController.signal, 38 38 getParams: async () => { 39 - if (!opts.getCursor) return undefined; 40 - const cursor = await opts.getCursor(); 41 - return { cursor }; 39 + if (!opts.getCursor) return undefined 40 + const cursor = await opts.getCursor() 41 + return { cursor } 42 42 }, 43 43 validate: (value: unknown) => { 44 44 try { 45 - return isValidRepoEvent(value); 45 + return isValidRepoEvent(value) 46 46 } catch (err) { 47 - console.error("repo subscription skipped invalid message", err); 47 + console.error('repo subscription skipped invalid message', err) 48 48 } 49 49 }, 50 - }); 50 + }) 51 51 } 52 52 53 53 async *run(): AsyncGenerator<Event> { ··· 55 55 for await (const evt of this.sub) { 56 56 try { 57 57 if (isCommit(evt) && !this.opts.excludeCommit) { 58 - const parsed = await parseCommit(evt); 58 + const parsed = await parseCommit(evt) 59 59 for (const write of parsed) { 60 - if ( 61 - !this.opts.filterCollections || 62 - this.opts.filterCollections.includes(write.uri.collection) 63 - ) { 64 - yield write; 60 + if (!this.opts.filterCollections || this.opts.filterCollections.includes(write.uri.collection)) { 61 + yield write 65 62 } 66 63 } 67 64 } else if (isAccount(evt) && !this.opts.excludeAccount) { 68 - const parsed = parseAccount(evt); 65 + const parsed = parseAccount(evt) 69 66 if (parsed) { 70 - yield parsed; 67 + yield parsed 71 68 } 72 69 } else if (isIdentity(evt) && !this.opts.excludeIdentity) { 73 - yield parseIdentity(evt); 70 + yield parseIdentity(evt) 74 71 } 75 72 } catch (err) { 76 - console.error("repo subscription could not handle message", err); 73 + console.error('repo subscription could not handle message', err) 77 74 } 78 - if (this.opts.setCursor && typeof evt.seq === "number") { 79 - await this.opts.setCursor(evt.seq); 75 + if (this.opts.setCursor && typeof evt.seq === 'number') { 76 + await this.opts.setCursor(evt.seq) 80 77 } 81 78 } 82 79 } catch (err) { 83 - console.error("repo subscription errored", err); 84 - setTimeout( 85 - () => this.run(), 86 - this.opts.subscriptionReconnectDelay ?? 3000 87 - ); 80 + console.error('repo subscription errored', err) 81 + setTimeout(() => this.run(), this.opts.subscriptionReconnectDelay ?? 3000) 88 82 } 89 83 } 90 84 91 85 destroy() { 92 - this.abortController.abort(); 86 + this.abortController.abort() 93 87 } 94 88 } 95 89 96 90 export const parseCommit = async (evt: Commit): Promise<CommitEvt[]> => { 97 - const car = await readCar(evt.blocks); 91 + const car = await readCar(evt.blocks) 98 92 99 - const evts: CommitEvt[] = []; 93 + const evts: CommitEvt[] = [] 100 94 101 95 for (const op of evt.ops) { 102 - const uri = new AtUri(`at://${evt.repo}/${op.path}`); 96 + const uri = new AtUri(`at://${evt.repo}/${op.path}`) 103 97 104 98 const meta: CommitMeta = { 105 99 uri, 106 100 author: uri.host, 107 101 collection: uri.collection, 108 102 rkey: uri.rkey, 109 - }; 103 + } 110 104 111 - if (op.action === "create" || op.action === "update") { 112 - if (!op.cid) continue; 113 - const recordBytes = car.blocks.get(op.cid); 114 - if (!recordBytes) continue; 115 - const record = cborToLexRecord(recordBytes); 105 + if (op.action === 'create' || op.action === 'update') { 106 + if (!op.cid) continue 107 + const recordBytes = car.blocks.get(op.cid) 108 + if (!recordBytes) continue 109 + const record = cborToLexRecord(recordBytes) 116 110 evts.push({ 117 111 ...meta, 118 - event: op.action as "create" | "update", 112 + event: op.action as 'create' | 'update', 119 113 cid: op.cid, 120 114 record, 121 - }); 115 + }) 122 116 } 123 117 124 - if (op.action === "delete") { 118 + if (op.action === 'delete') { 125 119 evts.push({ 126 120 ...meta, 127 - event: "delete", 128 - }); 121 + event: 'delete', 122 + }) 129 123 } 130 124 } 131 125 132 - return evts; 133 - }; 126 + return evts 127 + } 134 128 135 129 export const parseIdentity = (evt: Identity): IdentityEvt => { 136 130 return { 137 - event: "identity", 131 + event: 'identity', 138 132 did: evt.did, 139 133 handle: evt.handle, 140 - }; 141 - }; 134 + } 135 + } 142 136 143 137 export const parseAccount = (evt: Account): AccountEvt | undefined => { 144 - if (evt.status && !isValidStatus(evt.status)) return; 138 + if (evt.status && !isValidStatus(evt.status)) return 145 139 return { 146 - event: "account", 140 + event: 'account', 147 141 did: evt.did, 148 142 active: evt.active, 149 143 status: evt.status as AccountStatus, 150 - }; 151 - }; 144 + } 145 + } 152 146 153 147 const isValidStatus = (str: string): str is AccountStatus => { 154 - return ["takendown", "suspended", "deleted", "deactivated"].includes(str); 155 - }; 148 + return ['takendown', 'suspended', 'deleted', 'deactivated'].includes(str) 149 + } 156 150 157 - type Event = CommitEvt | IdentityEvt | AccountEvt; 151 + type Event = CommitEvt | IdentityEvt | AccountEvt 158 152 159 153 type CommitMeta = { 160 - uri: AtUri; 161 - author: string; 162 - collection: string; 163 - rkey: string; 164 - }; 154 + uri: AtUri 155 + author: string 156 + collection: string 157 + rkey: string 158 + } 165 159 166 - type CommitEvt = Create | Update | Delete; 160 + type CommitEvt = Create | Update | Delete 167 161 168 162 type Create = CommitMeta & { 169 - event: "create"; 170 - record: RepoRecord; 171 - cid: CID; 172 - }; 163 + event: 'create' 164 + record: RepoRecord 165 + cid: CID 166 + } 173 167 174 168 type Update = CommitMeta & { 175 - event: "update"; 176 - }; 169 + event: 'update' 170 + } 177 171 178 172 type Delete = CommitMeta & { 179 - event: "delete"; 180 - }; 173 + event: 'delete' 174 + } 181 175 182 176 type IdentityEvt = { 183 - event: "identity"; 184 - did: string; 185 - handle?: string; 186 - }; 177 + event: 'identity' 178 + did: string 179 + handle?: string 180 + } 187 181 188 182 type AccountEvt = { 189 - event: "account"; 190 - did: string; 191 - active: boolean; 192 - status?: AccountStatus; 193 - }; 183 + event: 'account' 184 + did: string 185 + active: boolean 186 + status?: AccountStatus 187 + } 194 188 195 - type AccountStatus = "takendown" | "suspended" | "deleted" | "deactivated"; 189 + type AccountStatus = 'takendown' | 'suspended' | 'deleted' | 'deactivated'
+10 -10
src/firehose/ingester.ts
··· 1 - import type { Database } from "#/db"; 2 - import { Firehose } from "#/firehose/firehose"; 1 + import type { Database } from '#/db' 2 + import { Firehose } from '#/firehose/firehose' 3 3 4 4 export class Ingester { 5 - firehose: Firehose | undefined; 5 + firehose: Firehose | undefined 6 6 constructor(public db: Database) {} 7 7 8 8 async start() { 9 - const firehose = new Firehose({}); 9 + const firehose = new Firehose({}) 10 10 11 11 for await (const evt of firehose.run()) { 12 - if (evt.event === "create") { 13 - if (evt.collection !== "app.bsky.feed.post") continue; 14 - const post: any = evt.record; // @TODO fix types 12 + if (evt.event === 'create') { 13 + if (evt.collection !== 'app.bsky.feed.post') continue 14 + const post: any = evt.record // @TODO fix types 15 15 await this.db 16 - .insertInto("post") 16 + .insertInto('post') 17 17 .values({ 18 18 uri: evt.uri.toString(), 19 19 text: post.text as string, 20 20 indexedAt: new Date().toISOString(), 21 21 }) 22 - .execute(); 22 + .execute() 23 23 } 24 24 } 25 25 } 26 26 27 27 destroy() { 28 - this.firehose?.destroy(); 28 + this.firehose?.destroy() 29 29 } 30 30 }
+148 -205
src/firehose/lexicons.ts
··· 1 - import type { IncomingMessage } from "node:http"; 1 + import type { IncomingMessage } from 'node:http' 2 2 3 - import { type LexiconDoc, Lexicons } from "@atproto/lexicon"; 4 - import type { ErrorFrame, HandlerAuth } from "@atproto/xrpc-server"; 5 - import type { CID } from "multiformats/cid"; 3 + import { type LexiconDoc, Lexicons } from '@atproto/lexicon' 4 + import type { ErrorFrame, HandlerAuth } from '@atproto/xrpc-server' 5 + import type { CID } from 'multiformats/cid' 6 6 7 7 // @NOTE: this file is an ugly copy job of codegen output. I'd like to clean this whole thing up 8 8 9 9 export function isObj(v: unknown): v is Record<string, unknown> { 10 - return typeof v === "object" && v !== null; 10 + return typeof v === 'object' && v !== null 11 11 } 12 12 13 - export function hasProp<K extends PropertyKey>( 14 - data: object, 15 - prop: K 16 - ): data is Record<K, unknown> { 17 - return prop in data; 13 + export function hasProp<K extends PropertyKey>(data: object, prop: K): data is Record<K, unknown> { 14 + return prop in data 18 15 } 19 16 20 17 export interface QueryParams { 21 18 /** The last known event seq number to backfill from. */ 22 - cursor?: number; 19 + cursor?: number 23 20 } 24 21 25 22 export type RepoEvent = ··· 30 27 | Migrate 31 28 | Tombstone 32 29 | Info 33 - | { $type: string; [k: string]: unknown }; 34 - export type HandlerError = ErrorFrame<"FutureCursor" | "ConsumerTooSlow">; 35 - export type HandlerOutput = HandlerError | RepoEvent; 30 + | { $type: string; [k: string]: unknown } 31 + export type HandlerError = ErrorFrame<'FutureCursor' | 'ConsumerTooSlow'> 32 + export type HandlerOutput = HandlerError | RepoEvent 36 33 export type HandlerReqCtx<HA extends HandlerAuth = never> = { 37 - auth: HA; 38 - params: QueryParams; 39 - req: IncomingMessage; 40 - signal: AbortSignal; 41 - }; 42 - export type Handler<HA extends HandlerAuth = never> = ( 43 - ctx: HandlerReqCtx<HA> 44 - ) => AsyncIterable<HandlerOutput>; 34 + auth: HA 35 + params: QueryParams 36 + req: IncomingMessage 37 + signal: AbortSignal 38 + } 39 + export type Handler<HA extends HandlerAuth = never> = (ctx: HandlerReqCtx<HA>) => AsyncIterable<HandlerOutput> 45 40 46 41 /** Represents an update of repository state. Note that empty commits are allowed, which include no repo data changes, but an update to rev and signature. */ 47 42 export interface Commit { 48 43 /** The stream sequence number of this message. */ 49 - seq: number; 44 + seq: number 50 45 /** DEPRECATED -- unused */ 51 - rebase: boolean; 46 + rebase: boolean 52 47 /** Indicates that this commit contained too many ops, or data size was too large. Consumers will need to make a separate request to get missing data. */ 53 - tooBig: boolean; 48 + tooBig: boolean 54 49 /** The repo this event comes from. */ 55 - repo: string; 50 + repo: string 56 51 /** Repo commit object CID. */ 57 - commit: CID; 52 + commit: CID 58 53 /** DEPRECATED -- unused. WARNING -- nullable and optional; stick with optional to ensure golang interoperability. */ 59 - prev?: CID | null; 54 + prev?: CID | null 60 55 /** The rev of the emitted commit. Note that this information is also in the commit object included in blocks, unless this is a tooBig event. */ 61 - rev: string; 56 + rev: string 62 57 /** The rev of the last emitted commit from this repo (if any). */ 63 - since: string | null; 58 + since: string | null 64 59 /** CAR file containing relevant blocks, as a diff since the previous repo state. */ 65 - blocks: Uint8Array; 66 - ops: RepoOp[]; 67 - blobs: CID[]; 60 + blocks: Uint8Array 61 + ops: RepoOp[] 62 + blobs: CID[] 68 63 /** Timestamp of when this message was originally broadcast. */ 69 - time: string; 70 - [k: string]: unknown; 64 + time: string 65 + [k: string]: unknown 71 66 } 72 67 73 68 export function isCommit(v: unknown): v is Commit { 74 - return ( 75 - isObj(v) && 76 - hasProp(v, "$type") && 77 - v.$type === "com.atproto.sync.subscribeRepos#commit" 78 - ); 69 + return isObj(v) && hasProp(v, '$type') && v.$type === 'com.atproto.sync.subscribeRepos#commit' 79 70 } 80 71 81 72 /** Represents a change to an account's identity. Could be an updated handle, signing key, or pds hosting endpoint. Serves as a prod to all downstream services to refresh their identity cache. */ 82 73 export interface Identity { 83 - seq: number; 84 - did: string; 85 - time: string; 74 + seq: number 75 + did: string 76 + time: string 86 77 /** The current handle for the account, or 'handle.invalid' if validation fails. This field is optional, might have been validated or passed-through from an upstream source. Semantics and behaviors for PDS vs Relay may evolve in the future; see atproto specs for more details. */ 87 - handle?: string; 88 - [k: string]: unknown; 78 + handle?: string 79 + [k: string]: unknown 89 80 } 90 81 91 82 export function isIdentity(v: unknown): v is Identity { 92 - return ( 93 - isObj(v) && 94 - hasProp(v, "$type") && 95 - v.$type === "com.atproto.sync.subscribeRepos#identity" 96 - ); 83 + return isObj(v) && hasProp(v, '$type') && v.$type === 'com.atproto.sync.subscribeRepos#identity' 97 84 } 98 85 99 86 /** Represents a change to an account's status on a host (eg, PDS or Relay). The semantics of this event are that the status is at the host which emitted the event, not necessarily that at the currently active PDS. Eg, a Relay takedown would emit a takedown with active=false, even if the PDS is still active. */ 100 87 export interface Account { 101 - seq: number; 102 - did: string; 103 - time: string; 88 + seq: number 89 + did: string 90 + time: string 104 91 /** Indicates that the account has a repository which can be fetched from the host that emitted this event. */ 105 - active: boolean; 92 + active: boolean 106 93 /** If active=false, this optional field indicates a reason for why the account is not active. */ 107 - status?: 108 - | "takendown" 109 - | "suspended" 110 - | "deleted" 111 - | "deactivated" 112 - | (string & {}); 113 - [k: string]: unknown; 94 + status?: 'takendown' | 'suspended' | 'deleted' | 'deactivated' | (string & {}) 95 + [k: string]: unknown 114 96 } 115 97 116 98 export function isAccount(v: unknown): v is Account { 117 - return ( 118 - isObj(v) && 119 - hasProp(v, "$type") && 120 - v.$type === "com.atproto.sync.subscribeRepos#account" 121 - ); 99 + return isObj(v) && hasProp(v, '$type') && v.$type === 'com.atproto.sync.subscribeRepos#account' 122 100 } 123 101 124 102 /** DEPRECATED -- Use #identity event instead */ 125 103 export interface Handle { 126 - seq: number; 127 - did: string; 128 - handle: string; 129 - time: string; 130 - [k: string]: unknown; 104 + seq: number 105 + did: string 106 + handle: string 107 + time: string 108 + [k: string]: unknown 131 109 } 132 110 133 111 export function isHandle(v: unknown): v is Handle { 134 - return ( 135 - isObj(v) && 136 - hasProp(v, "$type") && 137 - v.$type === "com.atproto.sync.subscribeRepos#handle" 138 - ); 112 + return isObj(v) && hasProp(v, '$type') && v.$type === 'com.atproto.sync.subscribeRepos#handle' 139 113 } 140 114 141 115 /** DEPRECATED -- Use #account event instead */ 142 116 export interface Migrate { 143 - seq: number; 144 - did: string; 145 - migrateTo: string | null; 146 - time: string; 147 - [k: string]: unknown; 117 + seq: number 118 + did: string 119 + migrateTo: string | null 120 + time: string 121 + [k: string]: unknown 148 122 } 149 123 150 124 export function isMigrate(v: unknown): v is Migrate { 151 - return ( 152 - isObj(v) && 153 - hasProp(v, "$type") && 154 - v.$type === "com.atproto.sync.subscribeRepos#migrate" 155 - ); 125 + return isObj(v) && hasProp(v, '$type') && v.$type === 'com.atproto.sync.subscribeRepos#migrate' 156 126 } 157 127 158 128 /** DEPRECATED -- Use #account event instead */ 159 129 export interface Tombstone { 160 - seq: number; 161 - did: string; 162 - time: string; 163 - [k: string]: unknown; 130 + seq: number 131 + did: string 132 + time: string 133 + [k: string]: unknown 164 134 } 165 135 166 136 export function isTombstone(v: unknown): v is Tombstone { 167 - return ( 168 - isObj(v) && 169 - hasProp(v, "$type") && 170 - v.$type === "com.atproto.sync.subscribeRepos#tombstone" 171 - ); 137 + return isObj(v) && hasProp(v, '$type') && v.$type === 'com.atproto.sync.subscribeRepos#tombstone' 172 138 } 173 139 174 140 export interface Info { 175 - name: "OutdatedCursor" | (string & {}); 176 - message?: string; 177 - [k: string]: unknown; 141 + name: 'OutdatedCursor' | (string & {}) 142 + message?: string 143 + [k: string]: unknown 178 144 } 179 145 180 146 export function isInfo(v: unknown): v is Info { 181 - return ( 182 - isObj(v) && 183 - hasProp(v, "$type") && 184 - v.$type === "com.atproto.sync.subscribeRepos#info" 185 - ); 147 + return isObj(v) && hasProp(v, '$type') && v.$type === 'com.atproto.sync.subscribeRepos#info' 186 148 } 187 149 188 150 /** A repo operation, ie a mutation of a single record. */ 189 151 export interface RepoOp { 190 - action: "create" | "update" | "delete" | (string & {}); 191 - path: string; 152 + action: 'create' | 'update' | 'delete' | (string & {}) 153 + path: string 192 154 /** For creates and updates, the new record CID. For deletions, null. */ 193 - cid: CID | null; 194 - [k: string]: unknown; 155 + cid: CID | null 156 + [k: string]: unknown 195 157 } 196 158 197 159 export function isRepoOp(v: unknown): v is RepoOp { 198 - return ( 199 - isObj(v) && 200 - hasProp(v, "$type") && 201 - v.$type === "com.atproto.sync.subscribeRepos#repoOp" 202 - ); 160 + return isObj(v) && hasProp(v, '$type') && v.$type === 'com.atproto.sync.subscribeRepos#repoOp' 203 161 } 204 162 205 163 export const ComAtprotoSyncSubscribeRepos: LexiconDoc = { 206 164 lexicon: 1, 207 - id: "com.atproto.sync.subscribeRepos", 165 + id: 'com.atproto.sync.subscribeRepos', 208 166 defs: { 209 167 main: { 210 - type: "subscription", 211 - description: "Subscribe to repo updates", 168 + type: 'subscription', 169 + description: 'Subscribe to repo updates', 212 170 parameters: { 213 - type: "params", 171 + type: 'params', 214 172 properties: { 215 173 cursor: { 216 - type: "integer", 217 - description: "The last known event to backfill from.", 174 + type: 'integer', 175 + description: 'The last known event to backfill from.', 218 176 }, 219 177 }, 220 178 }, 221 179 message: { 222 180 schema: { 223 - type: "union", 181 + type: 'union', 224 182 refs: [ 225 - "lex:com.atproto.sync.subscribeRepos#commit", 226 - "lex:com.atproto.sync.subscribeRepos#handle", 227 - "lex:com.atproto.sync.subscribeRepos#migrate", 228 - "lex:com.atproto.sync.subscribeRepos#tombstone", 229 - "lex:com.atproto.sync.subscribeRepos#info", 183 + 'lex:com.atproto.sync.subscribeRepos#commit', 184 + 'lex:com.atproto.sync.subscribeRepos#handle', 185 + 'lex:com.atproto.sync.subscribeRepos#migrate', 186 + 'lex:com.atproto.sync.subscribeRepos#tombstone', 187 + 'lex:com.atproto.sync.subscribeRepos#info', 230 188 ], 231 189 }, 232 190 }, 233 191 errors: [ 234 192 { 235 - name: "FutureCursor", 193 + name: 'FutureCursor', 236 194 }, 237 195 { 238 - name: "ConsumerTooSlow", 196 + name: 'ConsumerTooSlow', 239 197 }, 240 198 ], 241 199 }, 242 200 commit: { 243 - type: "object", 244 - required: [ 245 - "seq", 246 - "rebase", 247 - "tooBig", 248 - "repo", 249 - "commit", 250 - "rev", 251 - "since", 252 - "blocks", 253 - "ops", 254 - "blobs", 255 - "time", 256 - ], 257 - nullable: ["prev", "since"], 201 + type: 'object', 202 + required: ['seq', 'rebase', 'tooBig', 'repo', 'commit', 'rev', 'since', 'blocks', 'ops', 'blobs', 'time'], 203 + nullable: ['prev', 'since'], 258 204 properties: { 259 205 seq: { 260 - type: "integer", 206 + type: 'integer', 261 207 }, 262 208 rebase: { 263 - type: "boolean", 209 + type: 'boolean', 264 210 }, 265 211 tooBig: { 266 - type: "boolean", 212 + type: 'boolean', 267 213 }, 268 214 repo: { 269 - type: "string", 270 - format: "did", 215 + type: 'string', 216 + format: 'did', 271 217 }, 272 218 commit: { 273 - type: "cid-link", 219 + type: 'cid-link', 274 220 }, 275 221 prev: { 276 - type: "cid-link", 222 + type: 'cid-link', 277 223 }, 278 224 rev: { 279 - type: "string", 280 - description: "The rev of the emitted commit", 225 + type: 'string', 226 + description: 'The rev of the emitted commit', 281 227 }, 282 228 since: { 283 - type: "string", 284 - description: "The rev of the last emitted commit from this repo", 229 + type: 'string', 230 + description: 'The rev of the last emitted commit from this repo', 285 231 }, 286 232 blocks: { 287 - type: "bytes", 288 - description: "CAR file containing relevant blocks", 233 + type: 'bytes', 234 + description: 'CAR file containing relevant blocks', 289 235 maxLength: 1000000, 290 236 }, 291 237 ops: { 292 - type: "array", 238 + type: 'array', 293 239 items: { 294 - type: "ref", 295 - ref: "lex:com.atproto.sync.subscribeRepos#repoOp", 240 + type: 'ref', 241 + ref: 'lex:com.atproto.sync.subscribeRepos#repoOp', 296 242 }, 297 243 maxLength: 200, 298 244 }, 299 245 blobs: { 300 - type: "array", 246 + type: 'array', 301 247 items: { 302 - type: "cid-link", 248 + type: 'cid-link', 303 249 }, 304 250 }, 305 251 time: { 306 - type: "string", 307 - format: "datetime", 252 + type: 'string', 253 + format: 'datetime', 308 254 }, 309 255 }, 310 256 }, 311 257 handle: { 312 - type: "object", 313 - required: ["seq", "did", "handle", "time"], 258 + type: 'object', 259 + required: ['seq', 'did', 'handle', 'time'], 314 260 properties: { 315 261 seq: { 316 - type: "integer", 262 + type: 'integer', 317 263 }, 318 264 did: { 319 - type: "string", 320 - format: "did", 265 + type: 'string', 266 + format: 'did', 321 267 }, 322 268 handle: { 323 - type: "string", 324 - format: "handle", 269 + type: 'string', 270 + format: 'handle', 325 271 }, 326 272 time: { 327 - type: "string", 328 - format: "datetime", 273 + type: 'string', 274 + format: 'datetime', 329 275 }, 330 276 }, 331 277 }, 332 278 migrate: { 333 - type: "object", 334 - required: ["seq", "did", "migrateTo", "time"], 335 - nullable: ["migrateTo"], 279 + type: 'object', 280 + required: ['seq', 'did', 'migrateTo', 'time'], 281 + nullable: ['migrateTo'], 336 282 properties: { 337 283 seq: { 338 - type: "integer", 284 + type: 'integer', 339 285 }, 340 286 did: { 341 - type: "string", 342 - format: "did", 287 + type: 'string', 288 + format: 'did', 343 289 }, 344 290 migrateTo: { 345 - type: "string", 291 + type: 'string', 346 292 }, 347 293 time: { 348 - type: "string", 349 - format: "datetime", 294 + type: 'string', 295 + format: 'datetime', 350 296 }, 351 297 }, 352 298 }, 353 299 tombstone: { 354 - type: "object", 355 - required: ["seq", "did", "time"], 300 + type: 'object', 301 + required: ['seq', 'did', 'time'], 356 302 properties: { 357 303 seq: { 358 - type: "integer", 304 + type: 'integer', 359 305 }, 360 306 did: { 361 - type: "string", 362 - format: "did", 307 + type: 'string', 308 + format: 'did', 363 309 }, 364 310 time: { 365 - type: "string", 366 - format: "datetime", 311 + type: 'string', 312 + format: 'datetime', 367 313 }, 368 314 }, 369 315 }, 370 316 info: { 371 - type: "object", 372 - required: ["name"], 317 + type: 'object', 318 + required: ['name'], 373 319 properties: { 374 320 name: { 375 - type: "string", 376 - knownValues: ["OutdatedCursor"], 321 + type: 'string', 322 + knownValues: ['OutdatedCursor'], 377 323 }, 378 324 message: { 379 - type: "string", 325 + type: 'string', 380 326 }, 381 327 }, 382 328 }, 383 329 repoOp: { 384 - type: "object", 330 + type: 'object', 385 331 description: 386 332 "A repo operation, ie a write of a single record. For creates and updates, cid is the record's CID as of this operation. For deletes, it's null.", 387 - required: ["action", "path", "cid"], 388 - nullable: ["cid"], 333 + required: ['action', 'path', 'cid'], 334 + nullable: ['cid'], 389 335 properties: { 390 336 action: { 391 - type: "string", 392 - knownValues: ["create", "update", "delete"], 337 + type: 'string', 338 + knownValues: ['create', 'update', 'delete'], 393 339 }, 394 340 path: { 395 - type: "string", 341 + type: 'string', 396 342 }, 397 343 cid: { 398 - type: "cid-link", 344 + type: 'cid-link', 399 345 }, 400 346 }, 401 347 }, 402 348 }, 403 - }; 349 + } 404 350 405 - const lexicons = new Lexicons([ComAtprotoSyncSubscribeRepos]); 351 + const lexicons = new Lexicons([ComAtprotoSyncSubscribeRepos]) 406 352 407 353 export const isValidRepoEvent = (evt: unknown) => { 408 - return lexicons.assertValidXrpcMessage<RepoEvent>( 409 - "com.atproto.sync.subscribeRepos", 410 - evt 411 - ); 412 - }; 354 + return lexicons.assertValidXrpcMessage<RepoEvent>('com.atproto.sync.subscribeRepos', evt) 355 + }
+10 -10
src/index.ts
··· 1 - import { Server } from "#/server"; 1 + import { Server } from '#/server' 2 2 3 3 const run = async () => { 4 - const server = await Server.create(); 4 + const server = await Server.create() 5 5 6 6 const onCloseSignal = async () => { 7 - setTimeout(() => process.exit(1), 10000).unref(); // Force shutdown after 10s 8 - await server.close(); 9 - process.exit(); 10 - }; 7 + setTimeout(() => process.exit(1), 10000).unref() // Force shutdown after 10s 8 + await server.close() 9 + process.exit() 10 + } 11 11 12 - process.on("SIGINT", onCloseSignal); 13 - process.on("SIGTERM", onCloseSignal); 14 - }; 12 + process.on('SIGINT', onCloseSignal) 13 + process.on('SIGTERM', onCloseSignal) 14 + } 15 15 16 - run(); 16 + run()
+8 -8
src/middleware/errorHandler.ts
··· 1 - import type { ErrorRequestHandler, RequestHandler } from "express"; 2 - import { StatusCodes } from "http-status-codes"; 1 + import type { ErrorRequestHandler, RequestHandler } from 'express' 2 + import { StatusCodes } from 'http-status-codes' 3 3 4 4 const unexpectedRequest: RequestHandler = (_req, res) => { 5 - res.sendStatus(StatusCodes.NOT_FOUND); 6 - }; 5 + res.sendStatus(StatusCodes.NOT_FOUND) 6 + } 7 7 8 8 const addErrorToRequestLog: ErrorRequestHandler = (err, _req, res, next) => { 9 - res.locals.err = err; 10 - next(err); 11 - }; 9 + res.locals.err = err 10 + next(err) 11 + } 12 12 13 - export default () => [unexpectedRequest, addErrorToRequestLog]; 13 + export default () => [unexpectedRequest, addErrorToRequestLog]
+52 -52
src/middleware/requestLogger.ts
··· 1 - import { randomUUID } from "node:crypto"; 2 - import type { IncomingMessage, ServerResponse } from "node:http"; 3 - import type { Request, RequestHandler, Response } from "express"; 4 - import { StatusCodes, getReasonPhrase } from "http-status-codes"; 5 - import type { LevelWithSilent } from "pino"; 6 - import { type CustomAttributeKeys, type Options, pinoHttp } from "pino-http"; 1 + import { randomUUID } from 'node:crypto' 2 + import type { IncomingMessage, ServerResponse } from 'node:http' 3 + import type { Request, RequestHandler, Response } from 'express' 4 + import { StatusCodes, getReasonPhrase } from 'http-status-codes' 5 + import type { LevelWithSilent } from 'pino' 6 + import { type CustomAttributeKeys, type Options, pinoHttp } from 'pino-http' 7 7 8 - import { env } from "#/common/utils/envConfig"; 8 + import { env } from '#/env' 9 9 10 10 enum LogLevel { 11 - Fatal = "fatal", 12 - Error = "error", 13 - Warn = "warn", 14 - Info = "info", 15 - Debug = "debug", 16 - Trace = "trace", 17 - Silent = "silent", 11 + Fatal = 'fatal', 12 + Error = 'error', 13 + Warn = 'warn', 14 + Info = 'info', 15 + Debug = 'debug', 16 + Trace = 'trace', 17 + Silent = 'silent', 18 18 } 19 19 20 20 type PinoCustomProps = { 21 - request: Request; 22 - response: Response; 23 - error: Error; 24 - responseBody: unknown; 25 - }; 21 + request: Request 22 + response: Response 23 + error: Error 24 + responseBody: unknown 25 + } 26 26 27 27 const requestLogger = (options?: Options): RequestHandler[] => { 28 28 const pinoOptions: Options = { 29 29 enabled: env.isProduction, 30 - customProps: customProps as unknown as Options["customProps"], 30 + customProps: customProps as unknown as Options['customProps'], 31 31 redact: [], 32 32 genReqId, 33 33 customLogLevel, ··· 36 36 customErrorMessage: (_req, res) => `request errored with status code: ${res.statusCode}`, 37 37 customAttributeKeys, 38 38 ...options, 39 - }; 40 - return [responseBodyMiddleware, pinoHttp(pinoOptions)]; 41 - }; 39 + } 40 + return [responseBodyMiddleware, pinoHttp(pinoOptions)] 41 + } 42 42 43 43 const customAttributeKeys: CustomAttributeKeys = { 44 - req: "request", 45 - res: "response", 46 - err: "error", 47 - responseTime: "timeTaken", 48 - }; 44 + req: 'request', 45 + res: 'response', 46 + err: 'error', 47 + responseTime: 'timeTaken', 48 + } 49 49 50 50 const customProps = (req: Request, res: Response): PinoCustomProps => ({ 51 51 request: req, 52 52 response: res, 53 53 error: res.locals.err, 54 54 responseBody: res.locals.responseBody, 55 - }); 55 + }) 56 56 57 57 const responseBodyMiddleware: RequestHandler = (_req, res, next) => { 58 - const isNotProduction = !env.isProduction; 58 + const isNotProduction = !env.isProduction 59 59 if (isNotProduction) { 60 - const originalSend = res.send; 60 + const originalSend = res.send 61 61 res.send = (content) => { 62 - res.locals.responseBody = content; 63 - res.send = originalSend; 64 - return originalSend.call(res, content); 65 - }; 62 + res.locals.responseBody = content 63 + res.send = originalSend 64 + return originalSend.call(res, content) 65 + } 66 66 } 67 - next(); 68 - }; 67 + next() 68 + } 69 69 70 70 const customLogLevel = (_req: IncomingMessage, res: ServerResponse<IncomingMessage>, err?: Error): LevelWithSilent => { 71 - if (err || res.statusCode >= StatusCodes.INTERNAL_SERVER_ERROR) return LogLevel.Error; 72 - if (res.statusCode >= StatusCodes.BAD_REQUEST) return LogLevel.Warn; 73 - if (res.statusCode >= StatusCodes.MULTIPLE_CHOICES) return LogLevel.Silent; 74 - return LogLevel.Info; 75 - }; 71 + if (err || res.statusCode >= StatusCodes.INTERNAL_SERVER_ERROR) return LogLevel.Error 72 + if (res.statusCode >= StatusCodes.BAD_REQUEST) return LogLevel.Warn 73 + if (res.statusCode >= StatusCodes.MULTIPLE_CHOICES) return LogLevel.Silent 74 + return LogLevel.Info 75 + } 76 76 77 77 const customSuccessMessage = (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => { 78 - if (res.statusCode === StatusCodes.NOT_FOUND) return getReasonPhrase(StatusCodes.NOT_FOUND); 79 - return `${req.method} completed`; 80 - }; 78 + if (res.statusCode === StatusCodes.NOT_FOUND) return getReasonPhrase(StatusCodes.NOT_FOUND) 79 + return `${req.method} completed` 80 + } 81 81 82 82 const genReqId = (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => { 83 - const existingID = req.id ?? req.headers["x-request-id"]; 84 - if (existingID) return existingID; 85 - const id = randomUUID(); 86 - res.setHeader("X-Request-Id", id); 87 - return id; 88 - }; 83 + const existingID = req.id ?? req.headers['x-request-id'] 84 + if (existingID) return existingID 85 + const id = randomUUID() 86 + res.setHeader('X-Request-Id', id) 87 + return id 88 + } 89 89 90 - export default requestLogger(); 90 + export default requestLogger()
+12 -17
src/routes/index.ts
··· 1 - import express from "express"; 2 - import type { AppContext } from "#/config"; 3 - import { handler } from "./util"; 1 + import express from 'express' 2 + import type { AppContext } from '#/config' 3 + import { handler } from './util' 4 4 5 5 export const createRouter = (ctx: AppContext) => { 6 - const router = express.Router(); 6 + const router = express.Router() 7 7 8 8 router.get( 9 - "/", 9 + '/', 10 10 handler(async (req, res) => { 11 - const posts = await ctx.db 12 - .selectFrom("post") 13 - .selectAll() 14 - .orderBy("indexedAt", "desc") 15 - .limit(10) 16 - .execute(); 17 - const postTexts = posts.map((row) => row.text); 18 - res.json(postTexts); 19 - }) 20 - ); 11 + const posts = await ctx.db.selectFrom('post').selectAll().orderBy('indexedAt', 'desc').limit(10).execute() 12 + const postTexts = posts.map((row) => row.text) 13 + res.json(postTexts) 14 + }), 15 + ) 21 16 22 - return router; 23 - }; 17 + return router 18 + }
+5 -10
src/routes/util.ts
··· 1 - import type express from "express"; 1 + import type express from 'express' 2 2 3 3 export const handler = 4 - (fn: express.Handler) => 5 - async ( 6 - req: express.Request, 7 - res: express.Response, 8 - next: express.NextFunction 9 - ) => { 4 + (fn: express.Handler) => async (req: express.Request, res: express.Response, next: express.NextFunction) => { 10 5 try { 11 - await fn(req, res, next); 6 + await fn(req, res, next) 12 7 } catch (err) { 13 - next(err); 8 + next(err) 14 9 } 15 - }; 10 + }
+41 -41
src/server.ts
··· 1 - import events from "node:events"; 2 - import type http from "node:http"; 3 - import cors from "cors"; 4 - import express, { type Express } from "express"; 5 - import helmet from "helmet"; 6 - import { pino } from "pino"; 1 + import events from 'node:events' 2 + import type http from 'node:http' 3 + import cors from 'cors' 4 + import express, { type Express } from 'express' 5 + import helmet from 'helmet' 6 + import { pino } from 'pino' 7 7 8 - import { createDb, migrateToLatest } from "#/db"; 9 - import { env } from "#/env"; 10 - import { Ingester } from "#/firehose/ingester"; 11 - import errorHandler from "#/middleware/errorHandler"; 12 - import requestLogger from "#/middleware/requestLogger"; 13 - import { createRouter } from "#/routes"; 14 - import type { AppContext } from "./config"; 8 + import { createDb, migrateToLatest } from '#/db' 9 + import { env } from '#/env' 10 + import { Ingester } from '#/firehose/ingester' 11 + import errorHandler from '#/middleware/errorHandler' 12 + import requestLogger from '#/middleware/requestLogger' 13 + import { createRouter } from '#/routes' 14 + import type { AppContext } from './config' 15 15 16 16 export class Server { 17 17 constructor( 18 18 public app: express.Application, 19 19 public server: http.Server, 20 - public ctx: AppContext 20 + public ctx: AppContext, 21 21 ) {} 22 22 23 23 static async create() { 24 - const { NODE_ENV, HOST, PORT } = env; 24 + const { NODE_ENV, HOST, PORT } = env 25 25 26 - const logger = pino({ name: "server start" }); 27 - const db = createDb(":memory:"); 28 - await migrateToLatest(db); 29 - const ingester = new Ingester(db); 30 - ingester.start(); 26 + const logger = pino({ name: 'server start' }) 27 + const db = createDb(':memory:') 28 + await migrateToLatest(db) 29 + const ingester = new Ingester(db) 30 + ingester.start() 31 31 const ctx = { 32 32 db, 33 33 ingester, 34 34 logger, 35 - }; 35 + } 36 36 37 - const app: Express = express(); 37 + const app: Express = express() 38 38 39 39 // Set the application to trust the reverse proxy 40 - app.set("trust proxy", true); 40 + app.set('trust proxy', true) 41 41 42 42 // TODO: middleware for sqlite server 43 43 // TODO: middleware for OAuth 44 44 45 45 // Middlewares 46 - app.use(express.json()); 47 - app.use(express.urlencoded({ extended: true })); 48 - app.use(cors({ origin: env.CORS_ORIGIN, credentials: true })); 49 - app.use(helmet()); 46 + app.use(express.json()) 47 + app.use(express.urlencoded({ extended: true })) 48 + app.use(cors({ origin: env.CORS_ORIGIN, credentials: true })) 49 + app.use(helmet()) 50 50 51 51 // Request logging 52 - app.use(requestLogger); 52 + app.use(requestLogger) 53 53 54 54 // Routes 55 - const router = createRouter(ctx); 56 - app.use(router); 55 + const router = createRouter(ctx) 56 + app.use(router) 57 57 58 58 // Error handlers 59 - app.use(errorHandler()); 59 + app.use(errorHandler()) 60 60 61 - const server = app.listen(env.PORT); 62 - await events.once(server, "listening"); 63 - logger.info(`Server (${NODE_ENV}) running on port http://${HOST}:${PORT}`); 61 + const server = app.listen(env.PORT) 62 + await events.once(server, 'listening') 63 + logger.info(`Server (${NODE_ENV}) running on port http://${HOST}:${PORT}`) 64 64 65 - return new Server(app, server, ctx); 65 + return new Server(app, server, ctx) 66 66 } 67 67 68 68 async close() { 69 - this.ctx.logger.info("sigint received, shutting down"); 70 - this.ctx.ingester.destroy(); 69 + this.ctx.logger.info('sigint received, shutting down') 70 + this.ctx.ingester.destroy() 71 71 return new Promise<void>((resolve) => { 72 72 this.server.close(() => { 73 - this.ctx.logger.info("server closed"); 74 - resolve(); 75 - }); 76 - }); 73 + this.ctx.logger.info('server closed') 74 + resolve() 75 + }) 76 + }) 77 77 } 78 78 }