AppView in a box as a Vite plugin thing hatk.dev
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add JSDoc comments across core modules, move hooks out of oauth/

Add module-level docs with examples to labels, setup, seed, xrpc, and
hooks. Add function-level JSDoc to logger, mst, indexer, and all exports.
Move hooks.ts from oauth/ to src/ since it's generic lifecycle infrastructure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+325 -47
+68
packages/hatk/src/hooks.ts
··· 1 + /** 2 + * Lifecycle hooks that run in response to server events. 3 + * 4 + * Place hook modules in the `hooks/` directory. Currently supported hooks: 5 + * 6 + * - `on-login.ts` — called after each successful OAuth login 7 + * 8 + * Each hook default-exports an async function that receives an event-specific 9 + * context object. 10 + * 11 + * @example 12 + * ```ts 13 + * // hooks/on-login.ts 14 + * import type { OnLoginCtx } from '@hatk/hatk/hooks' 15 + * 16 + * export default async function (ctx: OnLoginCtx) { 17 + * // Ensure the user's repo is backfilled on first login 18 + * await ctx.ensureRepo(ctx.did) 19 + * } 20 + * ``` 21 + */ 22 + import { existsSync } from 'node:fs' 23 + import { resolve } from 'node:path' 24 + import { log } from './logger.ts' 25 + import { setRepoStatus } from './db.ts' 26 + import { triggerAutoBackfill } from './indexer.ts' 27 + 28 + /** Context passed to the on-login hook after a successful OAuth login. */ 29 + export type OnLoginCtx = { 30 + /** DID of the user who just logged in. */ 31 + did: string 32 + /** Trigger a backfill for a DID if it hasn't been indexed yet. */ 33 + ensureRepo: (did: string) => Promise<void> 34 + } 35 + 36 + type OnLoginHook = (ctx: OnLoginCtx) => Promise<void> 37 + 38 + let onLoginHook: OnLoginHook | null = null 39 + 40 + /** 41 + * Discover and load the on-login hook from the project's `hooks/` directory. 42 + * Looks for `on-login.ts` or `on-login.js`. Safe to call if no hook exists. 43 + */ 44 + export async function loadOnLoginHook(hooksDir: string): Promise<void> { 45 + const tsPath = resolve(hooksDir, 'on-login.ts') 46 + const jsPath = resolve(hooksDir, 'on-login.js') 47 + const path = existsSync(tsPath) ? tsPath : existsSync(jsPath) ? jsPath : null 48 + if (!path) return 49 + const mod = await import(path) 50 + onLoginHook = mod.default 51 + log('[hooks] on-login hook loaded') 52 + } 53 + 54 + /** Mark a DID as pending and trigger auto-backfill. */ 55 + async function ensureRepo(did: string): Promise<void> { 56 + await setRepoStatus(did, 'pending') 57 + triggerAutoBackfill(did) 58 + } 59 + 60 + /** Fire the on-login hook if loaded. Errors are logged but never block login. */ 61 + export async function fireOnLoginHook(did: string): Promise<void> { 62 + if (!onLoginHook) return 63 + try { 64 + await onLoginHook({ did, ensureRepo }) 65 + } catch (err: any) { 66 + console.error('[hooks] onLogin hook error:', err.message) 67 + } 68 + }
+33 -1
packages/hatk/src/indexer.ts
··· 8 8 import { getLexiconArray } from './schema.ts' 9 9 import { validateRecord } from '@bigmoves/lexicon' 10 10 11 + /** A record pending insertion, buffered to enable batched writes. */ 11 12 interface WriteBuffer { 12 13 collection: string 13 14 uri: string ··· 43 44 let indexerMaxRetries: number 44 45 let maxConcurrentBackfills = 3 45 46 47 + /** 48 + * Flush the write buffer — insert all buffered records, update the relay cursor, 49 + * run label rules on inserted records, and trigger FTS rebuilds when the write 50 + * threshold is reached. Emits a wide event with batch stats. 51 + */ 46 52 async function flushBuffer(): Promise<void> { 47 53 if (buffer.length === 0) return 48 54 const elapsed = timer() ··· 108 114 } 109 115 } 110 116 117 + /** Schedule a flush after FLUSH_INTERVAL_MS if one isn't already pending. */ 111 118 function scheduleFlush(): void { 112 119 if (flushTimer) return 113 120 flushTimer = setTimeout(async () => { ··· 116 123 }, FLUSH_INTERVAL_MS) 117 124 } 118 125 126 + /** Add a record to the write buffer. Flushes immediately if BATCH_SIZE is reached. */ 119 127 function bufferWrite(item: WriteBuffer): void { 120 128 buffer.push(item) 121 129 if (buffer.length >= BATCH_SIZE) { ··· 129 137 } 130 138 } 131 139 140 + /** 141 + * Auto-backfill a DID's repo when first seen on the firehose. 142 + * 143 + * Fetches the full repo via CAR export, inserts all records, then replays any 144 + * firehose events that arrived during the backfill. Concurrency is capped at 145 + * `maxConcurrentBackfills`. Failed backfills retry with exponential delay up 146 + * to `maxRetries`. 147 + */ 132 148 export async function triggerAutoBackfill(did: string, attempt = 0): Promise<void> { 133 149 if (backfillInFlight.has(did)) return 134 150 if (backfillInFlight.size >= maxConcurrentBackfills) { ··· 195 211 } 196 212 } 197 213 214 + /** Configuration for the firehose indexer. */ 198 215 interface IndexerOpts { 199 216 relayUrl: string 200 217 collections: Set<string> ··· 207 224 ftsRebuildInterval?: number 208 225 } 209 226 210 - // Periodic memory diagnostics 227 + /** Emit a memory diagnostics wide event every 30s for observability. */ 211 228 function startMemoryDiagnostics(): void { 212 229 setInterval(() => { 213 230 const mem = process.memoryUsage() ··· 230 247 }, 30_000) 231 248 } 232 249 250 + /** 251 + * Connect to the AT Protocol relay firehose and begin indexing. 252 + * 253 + * Opens a WebSocket to `subscribeRepos`, processes commit messages synchronously 254 + * on the event loop to minimize backpressure, and batches writes through 255 + * {@link flushBuffer}. New DIDs trigger auto-backfill via {@link triggerAutoBackfill}. 256 + * Reconnects automatically on disconnect after a 3s delay. 257 + * 258 + * @returns The WebSocket connection (for shutdown coordination) 259 + */ 233 260 export async function startIndexer(opts: IndexerOpts): Promise<WebSocket> { 234 261 const { relayUrl, collections, cursor, fetchTimeout } = opts 235 262 if (opts.ftsRebuildInterval != null) ftsRebuildInterval = opts.ftsRebuildInterval ··· 283 310 return ws 284 311 } 285 312 313 + /** 314 + * Process a single firehose message. Decodes the CBOR header/body, filters 315 + * for relevant collections, validates records against lexicons, and routes 316 + * writes to the buffer (or pending buffer if the DID is mid-backfill). 317 + */ 286 318 function processMessage(bytes: Uint8Array, collections: Set<string>): void { 287 319 const header = cborDecode(bytes, 0) 288 320 const body = cborDecode(bytes, header.offset)
+49
packages/hatk/src/labels.ts
··· 1 + /** 2 + * Label system for applying moderation labels to records as they are indexed. 3 + * 4 + * Place label modules in the `labels/` directory. Each module default-exports 5 + * an object with a `definition` (label metadata) and/or an `evaluate` function 6 + * (rule that returns label values for a given record). 7 + * 8 + * @example 9 + * ```ts 10 + * // labels/nsfw.ts 11 + * import type { LabelRuleContext } from '@hatk/hatk/labels' 12 + * 13 + * export default { 14 + * definition: { 15 + * identifier: 'nsfw', 16 + * severity: 'alert', 17 + * blurs: 'media', 18 + * defaultSetting: 'warn', 19 + * locales: [{ lang: 'en', name: 'NSFW', description: 'Not safe for work' }], 20 + * }, 21 + * 22 + * async evaluate(ctx: LabelRuleContext): Promise<string[]> { 23 + * if (ctx.record.value.nsfw === true) return ['nsfw'] 24 + * return [] 25 + * }, 26 + * } 27 + * ``` 28 + */ 1 29 import { resolve } from 'node:path' 2 30 import { readdirSync } from 'node:fs' 3 31 import type { LabelDefinition } from './config.ts' ··· 19 47 } 20 48 } 21 49 50 + /** Internal representation of a loaded label rule module. */ 22 51 interface LabelRule { 23 52 name: string 24 53 evaluate: (ctx: LabelRuleContext) => Promise<string[]> ··· 28 57 let labelDefs: LabelDefinition[] = [] 29 58 let labelSrc = 'self' 30 59 60 + /** 61 + * Discover and load label rule modules from the `labels/` directory. 62 + * 63 + * Each module should default-export an object with an optional `definition` 64 + * (label metadata like severity and blur behavior) and an optional `evaluate` 65 + * function that returns label values to apply to a record. 66 + * 67 + * @param labelsDir - Absolute path to the `labels/` directory 68 + */ 31 69 export async function initLabels(labelsDir: string): Promise<void> { 32 70 let files: string[] 33 71 try { ··· 65 103 } 66 104 } 67 105 106 + /** 107 + * Evaluate all loaded label rules against a record and persist any resulting labels. 108 + * Called after each record is indexed. Rule errors are logged but never block indexing. 109 + */ 68 110 export async function runLabelRules(record: { 69 111 uri: string 70 112 cid: string ··· 98 140 } 99 141 } 100 142 143 + /** 144 + * Re-evaluate all label rules against every existing record in the given collections. 145 + * Used by `/admin/rescan-labels` to apply new or updated rules retroactively. 146 + * 147 + * @returns Count of records scanned and new labels applied 148 + */ 101 149 export async function rescanLabels(collections: string[]): Promise<{ scanned: number; labeled: number }> { 102 150 const beforeRows = await querySQL(`SELECT COUNT(*) as count FROM _labels`) 103 151 const beforeCount = Number(beforeRows[0]?.count || 0) ··· 139 187 return { scanned, labeled: afterCount - beforeCount } 140 188 } 141 189 190 + /** Return all label definitions discovered during {@link initLabels}. */ 142 191 export function getLabelDefinitions(): LabelDefinition[] { 143 192 return labelDefs 144 193 }
+29
packages/hatk/src/logger.ts
··· 1 + /** 2 + * Unstructured debug log — use sparingly for human-readable dev output. 3 + * Prefer {@link emit} for anything that should be queryable in production. 4 + * Disabled when `DEBUG=0`. 5 + */ 1 6 export function log(...args: unknown[]): void { 2 7 if (process.env.DEBUG === '0') return 3 8 console.log(...args) 4 9 } 5 10 11 + /** 12 + * Emit a structured wide event as a single JSON line to stdout. 13 + * 14 + * Each call produces one canonical log line with a timestamp, module, operation, 15 + * and arbitrary key-value fields — designed for columnar search and aggregation, 16 + * not string grep. Pack as much context as possible into `fields` (request IDs, 17 + * durations, status codes, user DIDs, counts) so a single event tells the full 18 + * story. See https://loggingsucks.com for the philosophy behind this approach. 19 + * 20 + * Disabled when `DEBUG=0`. 21 + * 22 + * @param module - Subsystem emitting the event (e.g. "server", "indexer", "backfill") 23 + * @param op - Operation name (e.g. "request", "commit", "memory") 24 + * @param fields - High-cardinality key-value context — include everything relevant 25 + */ 6 26 export function emit(module: string, op: string, fields: Record<string, unknown>): void { 7 27 if (process.env.DEBUG === '0') return 8 28 const entry: Record<string, unknown> = { ··· 16 36 process.stdout.write(JSON.stringify(entry) + '\n') 17 37 } 18 38 39 + /** 40 + * Start a millisecond timer. Call the returned function to get elapsed ms. 41 + * Use with {@link emit} to add `duration_ms` to wide events. 42 + * 43 + * @example 44 + * const elapsed = timer() 45 + * await doWork() 46 + * emit('server', 'request', { path, status_code, duration_ms: elapsed() }) 47 + */ 19 48 export function timer(): () => number { 20 49 const start = performance.now() 21 50 return () => Math.round(performance.now() - start)
+1 -1
packages/hatk/src/main.ts
··· 23 23 import { relayHttpUrl } from './config.ts' 24 24 import { runBackfill } from './backfill.ts' 25 25 import { initOAuth } from './oauth/server.ts' 26 - import { loadOnLoginHook } from './oauth/hooks.ts' 26 + import { loadOnLoginHook } from './hooks.ts' 27 27 import { initSetup } from './setup.ts' 28 28 29 29 function logMemory(phase: string): void {
+18 -2
packages/hatk/src/mst.ts
··· 1 1 import { cborDecode } from './cbor.ts' 2 2 3 + /** A single entry from a Merkle Search Tree — a record path paired with its content CID. */ 3 4 export interface MstEntry { 4 - path: string // e.g. "xyz.marketplace.listing/3mfniulnr7c2g" 5 - cid: string // CID of the record block 5 + /** Record path, e.g. "xyz.marketplace.listing/3mfniulnr7c2g" */ 6 + path: string 7 + /** CID of the record's CBOR block */ 8 + cid: string 6 9 } 7 10 11 + /** 12 + * Walk an AT Protocol Merkle Search Tree (MST) in key order, yielding every record entry. 13 + * 14 + * The MST is a prefix-compressed B+ tree used by AT Protocol repositories to map 15 + * record paths to CIDs. Each node contains a left subtree pointer, an array of entries 16 + * (each with a prefix length, key suffix, value CID, and right subtree pointer), and 17 + * keys are reconstructed by combining the prefix of the previous key with the suffix. 18 + * 19 + * @param blocks - Block store that resolves CIDs to raw CBOR bytes 20 + * @param rootCid - CID of the MST root node 21 + * @yields {MstEntry} Record entries in lexicographic key order 22 + */ 8 23 export function* walkMst(blocks: { get(cid: string): Uint8Array | undefined }, rootCid: string): Generator<MstEntry> { 24 + /** Recursively visit an MST node, reconstructing full keys from prefix-compressed entries. */ 9 25 function* visit(cid: string, prefix: string): Generator<MstEntry, string> { 10 26 const data = blocks.get(cid) 11 27 if (!data) return prefix
-41
packages/hatk/src/oauth/hooks.ts
··· 1 - import { existsSync } from 'node:fs' 2 - import { resolve } from 'node:path' 3 - import { log } from '../logger.ts' 4 - import { setRepoStatus } from '../db.ts' 5 - import { triggerAutoBackfill } from '../indexer.ts' 6 - 7 - /** onLogin hook: called after each successful OAuth login. */ 8 - export type OnLoginCtx = { 9 - did: string 10 - ensureRepo: (did: string) => Promise<void> 11 - } 12 - 13 - type OnLoginHook = (ctx: OnLoginCtx) => Promise<void> 14 - 15 - let onLoginHook: OnLoginHook | null = null 16 - 17 - /** Load on-login hook from the exercise's hooks/ directory. */ 18 - export async function loadOnLoginHook(hooksDir: string): Promise<void> { 19 - const tsPath = resolve(hooksDir, 'on-login.ts') 20 - const jsPath = resolve(hooksDir, 'on-login.js') 21 - const path = existsSync(tsPath) ? tsPath : existsSync(jsPath) ? jsPath : null 22 - if (!path) return 23 - const mod = await import(path) 24 - onLoginHook = mod.default 25 - log('[oauth] on-login hook loaded') 26 - } 27 - 28 - async function ensureRepo(did: string): Promise<void> { 29 - await setRepoStatus(did, 'pending') 30 - triggerAutoBackfill(did) 31 - } 32 - 33 - /** Fire the onLogin hook if loaded. Errors are logged but don't block login. */ 34 - export async function fireOnLoginHook(did: string): Promise<void> { 35 - if (!onLoginHook) return 36 - try { 37 - await onLoginHook({ did, ensureRepo }) 38 - } catch (err: any) { 39 - console.error('[oauth] onLogin hook error:', err.message) 40 - } 41 - }
+1 -1
packages/hatk/src/oauth/server.ts
··· 33 33 } from './db.ts' 34 34 import { emit } from '../logger.ts' 35 35 import { querySQL } from '../db.ts' 36 - import { fireOnLoginHook } from './hooks.ts' 36 + import { fireOnLoginHook } from '../hooks.ts' 37 37 38 38 const SERVER_KEY_KID = 'appview-oauth-key' 39 39
+44
packages/hatk/src/seed.ts
··· 1 + /** 2 + * Test data seeding helpers for populating a local PDS. 3 + * 4 + * Place a seed script at `seeds/seed.ts`. It runs during `hatk dev` to create 5 + * accounts and records against your local PDS. Records are validated against 6 + * your project's lexicons before being written. 7 + * 8 + * @example 9 + * ```ts 10 + * // seeds/seed.ts 11 + * import { seed } from '../hatk.generated.ts' 12 + * 13 + * const { createAccount, createRecord } = seed() 14 + * 15 + * const alice = await createAccount('alice.test') 16 + * const bob = await createAccount('bob.test') 17 + * 18 + * await createRecord( 19 + * alice, 20 + * 'xyz.statusphere.status', 21 + * { status: '👍', createdAt: new Date().toISOString() }, 22 + * { rkey: 'status1' }, 23 + * ) 24 + * ``` 25 + */ 1 26 import { loadLexicons } from './schema.ts' 2 27 import { validateRecord } from '@bigmoves/lexicon' 3 28 import { resolve } from 'node:path' 4 29 import { readFileSync } from 'node:fs' 5 30 31 + /** Authenticated PDS session — returned by {@link seed.createAccount}. */ 6 32 export type Session = { did: string; accessJwt: string; handle: string } 33 + 34 + /** AT Protocol blob reference, as returned by `com.atproto.repo.uploadBlob`. */ 7 35 export type BlobRef = { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number } 36 + 37 + /** Options for the seed helper. All fields fall back to env vars or sensible defaults. */ 8 38 export type SeedOpts = { pds?: string; password?: string; lexicons?: string } 9 39 40 + /** 41 + * Create a seed helper for populating a local PDS with test data. 42 + * 43 + * Returns `createAccount`, `createRecord`, and `uploadBlob` functions bound to 44 + * the target PDS. Records are validated against the project's lexicons before 45 + * being written. Generic parameter `R` maps collection NSIDs to their record types 46 + * for type-safe seeding. 47 + * 48 + * @typeParam R - Map of collection NSID → record type (defaults to untyped) 49 + * @param opts - PDS URL, password, and lexicon directory overrides 50 + */ 10 51 export function seed<R extends Record<string, unknown> = Record<string, unknown>>(opts?: SeedOpts) { 11 52 const pdsUrl = opts?.pds || process.env.PDS_URL || 'http://localhost:2583' 12 53 const password = opts?.password || process.env.SEED_PASSWORD || 'password' 13 54 const lexiconsDir = resolve(opts?.lexicons || 'lexicons') 14 55 const lexiconArray = [...loadLexicons(lexiconsDir).values()] 15 56 57 + /** Create a PDS account (or reuse an existing one) and return an authenticated session. */ 16 58 async function createAccount(handle: string): Promise<Session> { 17 59 const res = await fetch(`${pdsUrl}/xrpc/com.atproto.server.createAccount`, { 18 60 method: 'POST', ··· 41 83 return { ...session, handle } 42 84 } 43 85 86 + /** Validate a record against its lexicon and write it to the PDS via `putRecord`. */ 44 87 async function createRecord<K extends keyof R & string>( 45 88 session: Session, 46 89 collection: K, ··· 74 117 return { uri, cid } 75 118 } 76 119 120 + /** Upload a file to the PDS as a blob. MIME type is inferred from the file extension. */ 77 121 async function uploadBlob(session: Session, filePath: string): Promise<BlobRef> { 78 122 const data = readFileSync(resolve(filePath)) 79 123 const ext = filePath.split('.').pop()?.toLowerCase() || ''
+36
packages/hatk/src/setup.ts
··· 1 + /** 2 + * Setup scripts that run once on server boot for initializing custom tables, 3 + * views, or other database state. 4 + * 5 + * Place scripts in the `setup/` directory. Each module default-exports a handler 6 + * function (or `{ handler }`) that receives a {@link SetupContext} with database 7 + * access. Scripts run in sorted filename order — prefix with numbers to control 8 + * execution order. Files starting with `_` are ignored. 9 + * 10 + * @example 11 + * ```ts 12 + * // setup/01-leaderboard.ts 13 + * import type { SetupContext } from '@hatk/hatk/setup' 14 + * 15 + * export default async function (ctx: SetupContext) { 16 + * await ctx.db.run(` 17 + * CREATE TABLE IF NOT EXISTS leaderboard ( 18 + * did TEXT PRIMARY KEY, 19 + * score INTEGER DEFAULT 0 20 + * ) 21 + * `) 22 + * } 23 + * ``` 24 + */ 1 25 import { resolve, relative } from 'node:path' 2 26 import { readdirSync, statSync } from 'node:fs' 3 27 import { log } from './logger.ts' 4 28 import { querySQL, runSQL } from './db.ts' 5 29 30 + /** Context passed to each setup script's handler function. */ 6 31 export interface SetupContext { 7 32 db: { 8 33 query: (sql: string, params?: any[]) => Promise<any[]> ··· 10 35 } 11 36 } 12 37 38 + /** Recursively collect .ts/.js files in a directory, skipping files prefixed with `_`. */ 13 39 function walkDir(dir: string): string[] { 14 40 const results: string[] = [] 15 41 try { ··· 25 51 return results.sort() 26 52 } 27 53 54 + /** 55 + * Run all setup scripts in the given directory on server boot. 56 + * 57 + * Each script should export a default handler function (or `{ handler }`) that 58 + * receives a {@link SetupContext} with database access. Scripts run in sorted 59 + * filename order — prefix with numbers (e.g. `01-create-tables.ts`) to control 60 + * execution order. Files starting with `_` are ignored. 61 + * 62 + * @param setupDir - Absolute path to the `setup/` directory 63 + */ 28 64 export async function initSetup(setupDir: string): Promise<void> { 29 65 const files = walkDir(setupDir) 30 66 if (files.length === 0) return
+1 -1
packages/hatk/src/test.ts
··· 16 16 import { initOpengraph } from './opengraph.ts' 17 17 import { initLabels } from './labels.ts' 18 18 import { discoverViews } from './views.ts' 19 - import { loadOnLoginHook } from './oauth/hooks.ts' 19 + import { loadOnLoginHook } from './hooks.ts' 20 20 import { validateLexicons } from '@bigmoves/lexicon' 21 21 import { packCursor, unpackCursor, isTakendownDid, filterTakendownDids } from './db.ts' 22 22 import { seed as createSeedHelpers, type SeedOpts } from './seed.ts'
+45
packages/hatk/src/xrpc.ts
··· 1 + /** 2 + * XRPC method handler system for serving AT Protocol endpoints. 3 + * 4 + * Place handler modules in the `xrpc/` directory, nested by NSID segments 5 + * (e.g. `xrpc/app/bsky/feed/getAuthorFeed.ts` → `app.bsky.feed.getAuthorFeed`). 6 + * Each module default-exports a `{ handler }` function that receives an 7 + * {@link XrpcContext} with database access, query params, pagination, and 8 + * viewer auth. 9 + * 10 + * @example 11 + * ```ts 12 + * // xrpc/xyz/statusphere/getStatuses.ts 13 + * import { defineXrpc } from '../../hatk.generated.ts' 14 + * 15 + * export default defineXrpc('xyz.statusphere.getStatuses', async (ctx) => { 16 + * const rows = await ctx.db.query('SELECT * FROM statusphere_status LIMIT ?', [ctx.limit]) 17 + * return { statuses: rows } 18 + * }) 19 + * ``` 20 + */ 1 21 import { resolve, relative } from 'node:path' 2 22 import { readdirSync, statSync } from 'node:fs' 3 23 import { log } from './logger.ts' ··· 20 40 21 41 export type { Row, FlatRow } 22 42 43 + /** Thrown from XRPC handlers to return a 400 response with an error message. */ 23 44 export class InvalidRequestError extends Error { 24 45 status = 400 25 46 errorName?: string ··· 28 49 this.errorName = errorName 29 50 } 30 51 } 52 + /** Thrown from XRPC handlers to return a 404 response. */ 31 53 export class NotFoundError extends InvalidRequestError { 32 54 status = 404 33 55 constructor(message = 'Not found') { ··· 35 57 } 36 58 } 37 59 60 + /** 61 + * Context passed to every XRPC handler. Provides database access, pagination 62 + * helpers, viewer auth, record resolution, full-text search, label queries, 63 + * and blob URL generation. 64 + * 65 + * @typeParam P - Query parameter types (derived from lexicon) 66 + * @typeParam Records - Map of collection NSID → record type (from generated types) 67 + * @typeParam I - Input body type for procedure calls 68 + */ 38 69 export interface XrpcContext< 39 70 P = Record<string, string>, 40 71 Records extends Record<string, any> = Record<string, any>, ··· 70 101 ) => string | undefined 71 102 } 72 103 104 + /** Internal representation of a loaded XRPC handler module. */ 73 105 interface XrpcHandler { 74 106 name: string 75 107 execute: ( ··· 83 115 84 116 let _relayUrl = '' 85 117 118 + /** Set the relay URL used for blob URL generation. Called once during boot. */ 86 119 export function configureRelay(relay: string) { 87 120 _relayUrl = relay 88 121 } 89 122 123 + /** 124 + * Generate a CDN URL for a blob ref. Uses the PDS directly in local dev, 125 + * or the Bluesky CDN (`cdn.bsky.app`) in production. 126 + */ 90 127 export function blobUrl( 91 128 did: string, 92 129 ref: unknown, ··· 103 140 104 141 const handlers = new Map<string, XrpcHandler>() 105 142 143 + /** Recursively collect .ts/.js files in a directory, skipping files prefixed with `_`. */ 106 144 function walkDir(dir: string): string[] { 107 145 const results: string[] = [] 108 146 try { ··· 118 156 return results.sort() 119 157 } 120 158 159 + /** 160 + * Discover and load XRPC handler modules from the `xrpc/` directory. 161 + * Directory nesting maps to NSID segments. Parameters are validated and 162 + * coerced against the matching lexicon definition. 163 + */ 121 164 export async function initXrpc(xrpcDir: string): Promise<void> { 122 165 const files = walkDir(xrpcDir) 123 166 if (files.length === 0) return ··· 190 233 } 191 234 } 192 235 236 + /** Execute a registered XRPC handler by name. Returns null if no handler matches. */ 193 237 export async function executeXrpc( 194 238 name: string, 195 239 params: Record<string, string>, ··· 203 247 return handler.execute(params, cursor, limit, viewer || null, input) 204 248 } 205 249 250 + /** Return all registered XRPC method names. */ 206 251 export function listXrpc(): string[] { 207 252 return Array.from(handlers.keys()) 208 253 }