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

Configure Feed

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

feat: consolidate server-side code into single server/ directory

Add define functions (defineFeed, defineQuery, defineProcedure, defineSetup,
defineHook, defineLabels, defineOG) with __type discriminants. Add recursive
scanner that discovers and categorizes server/ modules by type. Add
server-init.ts that wires scanner results to registration functions. Update
main.ts to use initServer() when server/ directory exists, with fallback to
legacy separate directories. Update codegen to scaffold server/ files and
export all define functions from hatk.generated.ts.

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

+462 -89
+1 -1
package-lock.json
··· 9000 9000 }, 9001 9001 "packages/hatk": { 9002 9002 "name": "@hatk/hatk", 9003 - "version": "0.0.1-alpha.22", 9003 + "version": "0.0.1-alpha.24", 9004 9004 "license": "MIT", 9005 9005 "dependencies": { 9006 9006 "@bigmoves/lexicon": "^0.2.1",
+1
packages/hatk/package.json
··· 20 20 "./xrpc-client": "./dist/xrpc-client.js", 21 21 "./views": "./dist/views.js", 22 22 "./seed": "./dist/seed.js", 23 + "./hooks": "./dist/hooks.js", 23 24 "./setup": "./dist/setup.js", 24 25 "./test": "./dist/test.js", 25 26 "./test/browser": "./dist/test-browser.js",
+40 -62
packages/hatk/src/cli.ts
··· 93 93 generate label <name> Generate a label definition 94 94 generate og <name> Generate an OpenGraph route 95 95 generate job <name> Generate a periodic job 96 + generate hook <name> Generate a lifecycle hook 97 + generate setup <name> Generate a setup script 96 98 generate types Regenerate TypeScript types from lexicons 97 99 destroy <type> <name> Remove a generated file 98 100 ··· 122 124 }, 123 125 }) 124 126 `, 125 - xrpc: (name) => `import { defineQuery } from '${xrpcImportPath(name)}' 127 + xrpc: (name) => `import { defineQuery } from '../hatk.generated.ts' 126 128 127 129 export default defineQuery('${name}', async (ctx) => { 128 130 const { ok, db, params, packCursor, unpackCursor } = ctx ··· 200 202 }, 201 203 } 202 204 `, 203 - } 205 + hook: (name) => `import { defineHook } from '../hatk.generated.ts' 204 206 205 - // Compute relative import path from xrpc/ns/id/method.ts back to hatk.generated.ts 206 - // e.g. fm.teal.getStats → xrpc/fm/teal/getStats.ts → needs ../../../hatk.generated.ts 207 - // Parts: [fm, teal, getStats] → 2 namespace dirs + xrpc/ dir = 3 levels up 208 - function xrpcImportPath(nsid: string) { 209 - const parts = nsid.split('.') 210 - const namespaceDirs = parts.length - 1 // dirs created from namespace segments 211 - return '../'.repeat(namespaceDirs + 1) + 'hatk.generated.ts' // +1 for xrpc/ dir itself 207 + export default defineHook('${name}', async (ctx) => { 208 + // Hook logic here 209 + }) 210 + `, 211 + setup: (_name) => `import { defineSetup } from '../hatk.generated.ts' 212 + 213 + export default defineSetup(async (ctx) => { 214 + // Setup logic here — runs before the server starts 215 + }) 216 + `, 212 217 } 213 218 214 219 const testTemplates: Record<string, (name: string) => string> = { ··· 321 326 } 322 327 323 328 const dirs: Record<string, string> = { 324 - feed: 'feeds', 325 - xrpc: 'xrpc', 326 - label: 'labels', 327 - og: 'og', 328 - job: 'jobs', 329 + feed: 'server', 330 + xrpc: 'server', 331 + label: 'server', 332 + og: 'server', 333 + job: 'server', 334 + hook: 'server', 335 + setup: 'server', 329 336 } 330 337 331 338 // --- Commands --- ··· 379 386 mkdirSync(dir) 380 387 const subs = [ 381 388 'lexicons', 382 - 'feeds', 383 - 'xrpc', 384 - 'og', 385 - 'labels', 386 - 'jobs', 389 + 'server', 387 390 'seeds', 388 - 'setup', 389 391 'public', 390 392 'test', 391 - 'test/feeds', 392 - 'test/xrpc', 393 + 'test/server', 393 394 'test/integration', 394 395 'test/browser', 395 396 'test/fixtures', ··· 1092 1093 allowImportingTsExtensions: true, 1093 1094 resolveJsonModule: true, 1094 1095 }, 1095 - include: ['feeds', 'xrpc', 'og', 'seeds', 'labels', 'jobs', 'setup', 'hatk.generated.ts', 'hatk.config.ts'], 1096 + include: ['server', 'seeds', 'hatk.generated.ts', 'hatk.config.ts'], 1096 1097 }, 1097 1098 null, 1098 1099 2, ··· 1355 1356 | Directory | Purpose | 1356 1357 |-------------|------------------------------------------------------| 1357 1358 | \`lexicons/\` | AT Protocol lexicon schemas (JSON). Defines collections and XRPC methods | 1358 - | \`feeds/\` | Feed generators — each file exports a feed via \`defineFeed\` | 1359 - | \`xrpc/\` | XRPC method handlers — directory nesting maps to NSID segments | 1360 - | \`labels/\` | Label definitions and rules for moderation | 1361 - | \`setup/\` | Boot-time scripts (run before server starts). Prefix with numbers for ordering | 1359 + | \`server/\` | All server-side code: feeds, XRPC handlers, hooks, labels, OG routes, jobs, setup scripts | 1362 1360 | \`seeds/\` | Test data seeding scripts for local development | 1363 - | \`hooks/\` | Lifecycle hooks (e.g. \`on-login.ts\`) | 1364 - | \`og/\` | OpenGraph image routes | 1365 - | \`jobs/\` | Periodic background tasks | 1366 1361 | \`test/\` | Test files (vitest). Run with \`hatk test\` | 1367 1362 | \`public/\` | Static files served at the root | 1368 1363 ··· 1386 1381 console.log(`Created ${name}/`) 1387 1382 console.log(` hatk.config.ts`) 1388 1383 console.log(` lexicons/ — lexicon JSON files (core + your own)`) 1389 - console.log(` feeds/ — feed generators`) 1390 - console.log(` xrpc/ — XRPC method handlers`) 1391 - console.log(` og/ — OpenGraph image routes`) 1392 - console.log(` labels/ — label definitions + rules`) 1393 - console.log(` jobs/ — periodic tasks`) 1384 + console.log(` server/ — feeds, XRPC handlers, hooks, labels, OG routes, jobs, setup`) 1394 1385 console.log(` seeds/ — seed fixture data (hatk seed)`) 1395 - console.log(` setup/ — boot-time setup scripts (run before server starts)`) 1396 1386 console.log(` test/ — test files (hatk test)`) 1397 1387 console.log(` public/ — static files`) 1398 1388 console.log(` docker-compose.yml — local PDS for development`) ··· 1654 1644 out += `\n// ─── XRPC Helpers ───────────────────────────────────────────────────\n\n` 1655 1645 out += `export type { HydrateContext } from '@hatk/hatk/feeds'\n` 1656 1646 out += `export { InvalidRequestError, NotFoundError } from '@hatk/hatk/xrpc'\n` 1647 + out += `export { defineSetup } from '@hatk/hatk/setup'\n` 1648 + out += `export { defineHook } from '@hatk/hatk/hooks'\n` 1649 + out += `export { defineLabels } from '@hatk/hatk/labels'\n` 1650 + out += `export { defineOG } from '@hatk/hatk/opengraph'\n` 1657 1651 out += `export type Ctx<K extends keyof XrpcSchema & keyof Registry> = XrpcContext<\n` 1658 1652 out += ` LexServerParams<Registry[K], Registry>,\n` 1659 1653 out += ` RecordRegistry,\n` ··· 1667 1661 out += ` nsid: K,\n` 1668 1662 out += ` handler: (ctx: Ctx<K> & { ok: <T extends OutputOf<K>>(value: StrictArg<T, OutputOf<K>>) => Checked<OutputOf<K>> }) => Promise<Checked<OutputOf<K>>>,\n` 1669 1663 out += `) {\n` 1670 - out += ` return { handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n` 1664 + out += ` return { __type: 'query' as const, nsid, handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n` 1671 1665 out += `}\n\n` 1672 1666 out += `export function defineProcedure<K extends keyof XrpcSchema & string>(\n` 1673 1667 out += ` nsid: K,\n` 1674 1668 out += ` handler: (ctx: Ctx<K> & { ok: <T extends OutputOf<K>>(value: StrictArg<T, OutputOf<K>>) => Checked<OutputOf<K>> }) => Promise<Checked<OutputOf<K>>>,\n` 1675 1669 out += `) {\n` 1676 - out += ` return { handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n` 1670 + out += ` return { __type: 'procedure' as const, nsid, handler: (ctx: any) => handler({ ...ctx, ok: (v: any) => v }) }\n` 1677 1671 out += `}\n\n` 1678 1672 out += `// ─── Feed & Seed Helpers ────────────────────────────────────────────\n\n` 1679 1673 out += `type FeedGenerate = (ctx: FeedContext & { ok: (value: FeedResult) => Checked<FeedResult> }) => Promise<Checked<FeedResult>>\n` ··· 1741 1735 } 1742 1736 1743 1737 const baseDir = dirs[type] 1744 - let filePath: string 1745 - if (type === 'xrpc') { 1746 - // NSID → folder path: fm.teal.getStats → xrpc/fm/teal/getStats.ts 1747 - const parts = name.split('.') 1748 - const subDir = join(baseDir, ...parts.slice(0, -1)) 1749 - mkdirSync(subDir, { recursive: true }) 1750 - filePath = join(subDir, `${parts[parts.length - 1]}.ts`) 1751 - } else { 1752 - mkdirSync(baseDir, { recursive: true }) 1753 - filePath = join(baseDir, `${name}.ts`) 1754 - } 1738 + mkdirSync(baseDir, { recursive: true }) 1739 + const fileName = type === 'xrpc' ? name.split('.').pop()! : name 1740 + const filePath = join(baseDir, `${fileName}.ts`) 1755 1741 1756 1742 if (existsSync(filePath)) { 1757 1743 console.error(`${filePath} already exists`) ··· 1764 1750 // Scaffold test file if template exists 1765 1751 const testTemplate = testTemplates[type] 1766 1752 if (testTemplate) { 1767 - const testDir = type === 'xrpc' ? 'test/xrpc' : `test/${baseDir}` 1753 + const testDir = 'test/server' 1768 1754 mkdirSync(testDir, { recursive: true }) 1769 1755 const testName = type === 'xrpc' ? name.split('.').pop()! : name 1770 1756 const testPath = join(testDir, `${testName}.test.ts`) ··· 1783 1769 } 1784 1770 1785 1771 const baseDir = dirs[type] 1786 - let tsPath: string, jsPath: string 1787 - if (type === 'xrpc') { 1788 - const parts = name.split('.') 1789 - const leaf = parts[parts.length - 1] 1790 - const subDir = join(baseDir, ...parts.slice(0, -1)) 1791 - tsPath = join(subDir, `${leaf}.ts`) 1792 - jsPath = join(subDir, `${leaf}.js`) 1793 - } else { 1794 - tsPath = join(baseDir, `${name}.ts`) 1795 - jsPath = join(baseDir, `${name}.js`) 1796 - } 1772 + const fileName = type === 'xrpc' ? name.split('.').pop()! : name 1773 + const tsPath = join(baseDir, `${fileName}.ts`) 1774 + const jsPath = join(baseDir, `${fileName}.js`) 1797 1775 const filePath = existsSync(tsPath) ? tsPath : existsSync(jsPath) ? jsPath : null 1798 1776 1799 1777 if (!filePath) { ··· 1805 1783 console.log(`Removed ${filePath}`) 1806 1784 1807 1785 // Clean up test file 1808 - const testDir = type === 'xrpc' ? 'test/xrpc' : `test/${baseDir}` 1786 + const testDir = 'test/server' 1809 1787 const testName = type === 'xrpc' ? name.split('.').pop()! : name 1810 1788 const testFile = join(testDir, `${testName}.test.ts`) 1811 1789 if (existsSync(testFile)) {
+43 -1
packages/hatk/src/feeds.ts
··· 134 134 } 135 135 136 136 export function defineFeed(opts: FeedOpts) { 137 - return { ...opts, generate: (ctx: any) => opts.generate({ ...ctx, ok: (v: any) => v }) } 137 + return { __type: 'feed' as const, ...opts, generate: (ctx: any) => opts.generate({ ...ctx, ok: (v: any) => v }) } 138 + } 139 + 140 + /** Register a single feed from a scanned server/ module. */ 141 + export function registerFeed(name: string, generator: ReturnType<typeof defineFeed>): void { 142 + const handler: FeedHandler = { 143 + name, 144 + label: generator.label || name, 145 + collection: generator.collection, 146 + view: generator.view, 147 + generate: async (params, cursor, limit, viewer) => { 148 + const paginateDeps = { 149 + db: { query: querySQL }, 150 + cursor, 151 + limit, 152 + packCursor, 153 + unpackCursor, 154 + } 155 + const ctx: FeedContext = { 156 + db: { query: querySQL }, 157 + params, 158 + cursor, 159 + limit, 160 + viewer, 161 + packCursor, 162 + unpackCursor, 163 + isTakendown: isTakendownDid, 164 + filterTakendownDids, 165 + paginate: createPaginate(paginateDeps), 166 + } 167 + const result = await generator.generate(ctx) 168 + if (Array.isArray(result)) { 169 + return { uris: result.map((r: any) => r.uri || r) } 170 + } 171 + return { uris: result.uris, cursor: result.cursor } 172 + }, 173 + } 174 + 175 + if (typeof generator.hydrate === 'function') { 176 + handler.hydrate = generator.hydrate 177 + } 178 + 179 + feeds.set(name, handler) 138 180 } 139 181 140 182 const feeds = new Map<string, FeedHandler>()
+12
packages/hatk/src/hooks.ts
··· 33 33 ensureRepo: (did: string) => Promise<void> 34 34 } 35 35 36 + export function defineHook(event: 'on-login', handler: (ctx: OnLoginCtx) => Promise<void>) { 37 + return { __type: 'hook' as const, event, handler } 38 + } 39 + 36 40 type OnLoginHook = (ctx: OnLoginCtx) => Promise<void> 37 41 38 42 let onLoginHook: OnLoginHook | null = null ··· 55 59 async function ensureRepo(did: string): Promise<void> { 56 60 await setRepoStatus(did, 'pending') 57 61 triggerAutoBackfill(did) 62 + } 63 + 64 + /** Register a hook from a scanned server/ module. */ 65 + export function registerHook(event: string, handler: Function): void { 66 + if (event === 'on-login') { 67 + onLoginHook = handler as OnLoginHook 68 + log('[hooks] on-login hook registered') 69 + } 58 70 } 59 71 60 72 /** Fire the on-login hook if loaded. Errors are logged but never block login. */
+19
packages/hatk/src/labels.ts
··· 47 47 } 48 48 } 49 49 50 + export interface LabelModule { 51 + definition?: LabelDefinition 52 + evaluate?: (ctx: LabelRuleContext) => Promise<string[]> 53 + } 54 + 55 + export function defineLabels(module: LabelModule) { 56 + return { __type: 'labels' as const, ...module } 57 + } 58 + 50 59 /** Internal representation of a loaded label rule module. */ 51 60 interface LabelRule { 52 61 name: string ··· 100 109 101 110 if (labelDefs.length > 0) { 102 111 log(`[labels] ${labelDefs.length} label definitions loaded`) 112 + } 113 + } 114 + 115 + /** Register a single label module from a scanned server/ module. */ 116 + export function registerLabelModule(name: string, labelMod: { definition?: LabelDefinition; evaluate?: (ctx: LabelRuleContext) => Promise<string[]> }): void { 117 + if (labelMod.definition) { 118 + labelDefs.push(labelMod.definition) 119 + } 120 + if (labelMod.evaluate) { 121 + rules.push({ name, evaluate: labelMod.evaluate }) 103 122 } 104 123 } 105 124
+20 -23
packages/hatk/src/main.ts
··· 1 1 #!/usr/bin/env node 2 - import { mkdirSync, writeFileSync } from 'node:fs' 2 + import { mkdirSync, writeFileSync, existsSync } from 'node:fs' 3 3 import { dirname, resolve } from 'node:path' 4 4 import { log } from './logger.ts' 5 5 import { loadConfig } from './config.ts' ··· 22 22 import { initOAuth } from './oauth/server.ts' 23 23 import { loadOnLoginHook } from './hooks.ts' 24 24 import { initSetup } from './setup.ts' 25 + import { initServer } from './server-init.ts' 25 26 26 27 function logMemory(phase: string): void { 27 28 const mem = process.memoryUsage() ··· 63 64 64 65 // Discover view defs from lexicons 65 66 discoverViews() 66 - await loadOnLoginHook(resolve(configDir, 'hooks')) 67 67 68 68 const engineDialect = getDialect(config.databaseEngine) 69 69 const { schemas, ddlStatements } = buildSchemas(lexicons, collections, engineDialect) ··· 93 93 log(`[main] Applied ${migrationChanges.length} schema migration(s)`) 94 94 } 95 95 96 - // 3b. Run setup hooks (after DB init, before server) 97 - await initSetup(resolve(configDir, 'setup')) 96 + // 3b. Run setup hooks, feeds, xrpc, og, labels 97 + const serverDir = resolve(configDir, 'server') 98 + if (existsSync(serverDir)) { 99 + // New: single server/ directory 100 + await initServer(serverDir) 101 + } else { 102 + // Legacy: separate directories 103 + await initSetup(resolve(configDir, 'setup')) 104 + await loadOnLoginHook(resolve(configDir, 'hooks')) 105 + await initFeeds(resolve(configDir, 'feeds')) 106 + log(`[main] Feeds initialized: ${listFeeds().map((f) => f.name).join(', ') || 'none'}`) 107 + await initXrpc(resolve(configDir, 'xrpc')) 108 + log(`[main] XRPC handlers initialized: ${listXrpc().join(', ') || 'none'}`) 109 + await initOpengraph(resolve(configDir, 'og')) 110 + log(`[main] OpenGraph initialized`) 111 + await initLabels(resolve(configDir, 'labels')) 112 + log(`[main] Labels initialized: ${getLabelDefinitions().length} definitions`) 113 + } 98 114 99 115 // Write db/schema.sql (after setup, so setup-created tables are included) 100 116 try { ··· 121 137 } 122 138 } 123 139 } catch {} 124 - 125 - // 4. Initialize feeds, xrpc handlers, og, labels from directories 126 - await initFeeds(resolve(configDir, 'feeds')) 127 - log( 128 - `[main] Feeds initialized: ${ 129 - listFeeds() 130 - .map((f) => f.name) 131 - .join(', ') || 'none' 132 - }`, 133 - ) 134 - 135 - await initXrpc(resolve(configDir, 'xrpc')) 136 - log(`[main] XRPC handlers initialized: ${listXrpc().join(', ') || 'none'}`) 137 - 138 - await initOpengraph(resolve(configDir, 'og')) 139 - log(`[main] OpenGraph initialized`) 140 - 141 - await initLabels(resolve(configDir, 'labels')) 142 - log(`[main] Labels initialized: ${getLabelDefinitions().length} definitions`) 143 140 144 141 if (config.oauth) { 145 142 await initOAuth(config.oauth, config.plc, config.relay)
+90
packages/hatk/src/opengraph.ts
··· 45 45 meta?: { title?: string; description?: string } 46 46 } 47 47 48 + export function defineOG( 49 + path: string, 50 + generate: (ctx: OpengraphContext) => Promise<OpengraphResult>, 51 + ) { 52 + return { __type: 'og' as const, path, generate } 53 + } 54 + 48 55 interface OgHandler { 49 56 name: string 50 57 path: string ··· 177 184 pageRoutes.push({ ogPath: handler.path, pattern: compiled.pattern, paramNames: compiled.paramNames, name }) 178 185 } 179 186 } 187 + } 188 + 189 + /** Register a single OG handler from a scanned server/ module. */ 190 + export function registerOgHandler(ogMod: { path: string; generate: (ctx: OpengraphContext) => Promise<OpengraphResult> }): void { 191 + const { pattern, paramNames } = compilePath(ogMod.path) 192 + const name = ogMod.path.replace(/^\//, '').replace(/\//g, '-').replace(/:/g, '') 193 + 194 + // Load default font if not already loaded 195 + if (!defaultFont) { 196 + try { 197 + const fontPath = resolve(import.meta.dirname, '..', 'fonts', 'Inter-Regular.woff') 198 + const fontData = readFileSync(fontPath) 199 + defaultFont = { name: 'Inter', data: fontData.buffer as ArrayBuffer, weight: 400, style: 'normal' } 200 + } catch {} 201 + } 202 + 203 + handlers.push({ 204 + name, 205 + path: ogMod.path, 206 + pattern, 207 + paramNames, 208 + execute: async (params) => { 209 + const ctx: XrpcContext = { 210 + db: { query: querySQL, run: runSQL }, 211 + params, 212 + input: {}, 213 + limit: 1, 214 + viewer: null, 215 + packCursor, 216 + unpackCursor, 217 + isTakendown: isTakendownDid, 218 + filterTakendownDids, 219 + search: searchRecords, 220 + resolve: resolveRecords as any, 221 + lookup: async (collection, field, values) => { 222 + if (values.length === 0) return new Map() 223 + const unique = [...new Set(values.filter(Boolean))] 224 + return lookupByFieldBatch(collection, field, unique) as any 225 + }, 226 + count: async (collection, field, values) => { 227 + if (values.length === 0) return new Map() 228 + const unique = [...new Set(values.filter(Boolean))] 229 + return countByFieldBatch(collection, field, unique) 230 + }, 231 + exists: async (collection, filters) => { 232 + const conditions = Object.entries(filters).map(([field, value]) => ({ field, value })) 233 + const uri = await findUriByFields(collection, conditions) 234 + return uri !== null 235 + }, 236 + labels: queryLabelsForUris, 237 + blobUrl, 238 + } 239 + ;(ctx as any).fetchImage = async (url: string): Promise<string | null> => { 240 + try { 241 + const resp = await fetch(url, { redirect: 'follow' }) 242 + if (!resp.ok) return null 243 + const buf = Buffer.from(await resp.arrayBuffer()) 244 + const contentType = resp.headers.get('content-type') || 'image/jpeg' 245 + return `data:${contentType};base64,${buf.toString('base64')}` 246 + } catch { 247 + return null 248 + } 249 + } 250 + const result = await ogMod.generate(ctx as OpengraphContext) 251 + const element = result.element 252 + const options = { 253 + width: 1200, 254 + height: 630, 255 + ...result.options, 256 + fonts: [...(defaultFont ? [defaultFont] : []), ...(result.options?.fonts || [])], 257 + } 258 + const svg = await satori(element as any, options) 259 + return { svg, meta: result.meta } 260 + }, 261 + }) 262 + 263 + const pagePath = ogMod.path.replace(/^\/og/, '') 264 + if (pagePath !== ogMod.path) { 265 + const compiled = compilePath(pagePath) 266 + pageRoutes.push({ ogPath: ogMod.path, pattern: compiled.pattern, paramNames: compiled.paramNames, name }) 267 + } 268 + 269 + log(`[opengraph] registered: ${name} → ${ogMod.path}`) 180 270 } 181 271 182 272 export async function handleOpengraphRequest(pathname: string): Promise<Buffer | null> {
+97
packages/hatk/src/scanner.ts
··· 1 + import { resolve, relative } from 'node:path' 2 + import { readdirSync, statSync, existsSync } from 'node:fs' 3 + import { log } from './logger.ts' 4 + 5 + export interface ScannedModule { 6 + path: string 7 + name: string 8 + mod: any 9 + } 10 + 11 + export interface ScanResult { 12 + feeds: ScannedModule[] 13 + queries: ScannedModule[] 14 + procedures: ScannedModule[] 15 + hooks: ScannedModule[] 16 + setup: ScannedModule[] 17 + labels: ScannedModule[] 18 + og: ScannedModule[] 19 + } 20 + 21 + /** Recursively collect .ts/.js files, skipping _ prefixed and dot files */ 22 + function walkDir(dir: string): string[] { 23 + const results: string[] = [] 24 + try { 25 + for (const entry of readdirSync(dir)) { 26 + if (entry.startsWith('_') || entry.startsWith('.')) continue 27 + const full = resolve(dir, entry) 28 + if (statSync(full).isDirectory()) { 29 + results.push(...walkDir(full)) 30 + } else if (entry.endsWith('.ts') || entry.endsWith('.js')) { 31 + results.push(full) 32 + } 33 + } 34 + } catch {} 35 + return results.sort() 36 + } 37 + 38 + /** 39 + * Scan a directory for hatk server modules. 40 + * Each file's default export is inspected for a `__type` tag. 41 + */ 42 + export async function scanServerDir(serverDir: string): Promise<ScanResult> { 43 + const result: ScanResult = { 44 + feeds: [], 45 + queries: [], 46 + procedures: [], 47 + hooks: [], 48 + setup: [], 49 + labels: [], 50 + og: [], 51 + } 52 + 53 + if (!existsSync(serverDir)) return result 54 + 55 + const files = walkDir(serverDir) 56 + 57 + for (const filePath of files) { 58 + const name = relative(serverDir, filePath).replace(/\.(ts|js)$/, '') 59 + const mod = await import(filePath) 60 + const exported = mod.default 61 + 62 + if (!exported) { 63 + log(`[scanner] ${name}: no default export, skipping`) 64 + continue 65 + } 66 + 67 + const entry: ScannedModule = { path: filePath, name, mod: exported } 68 + 69 + switch (exported.__type) { 70 + case 'feed': 71 + result.feeds.push(entry) 72 + break 73 + case 'query': 74 + result.queries.push(entry) 75 + break 76 + case 'procedure': 77 + result.procedures.push(entry) 78 + break 79 + case 'hook': 80 + result.hooks.push(entry) 81 + break 82 + case 'setup': 83 + result.setup.push(entry) 84 + break 85 + case 'labels': 86 + result.labels.push(entry) 87 + break 88 + case 'og': 89 + result.og.push(entry) 90 + break 91 + default: 92 + log(`[scanner] ${name}: no recognized __type tag, skipping`) 93 + } 94 + } 95 + 96 + return result 97 + }
+61
packages/hatk/src/server-init.ts
··· 1 + import { existsSync } from 'node:fs' 2 + import { log } from './logger.ts' 3 + import { scanServerDir } from './scanner.ts' 4 + import { registerFeed, listFeeds } from './feeds.ts' 5 + import { registerXrpcHandler, listXrpc } from './xrpc.ts' 6 + import { registerLabelModule, getLabelDefinitions } from './labels.ts' 7 + import { registerOgHandler } from './opengraph.ts' 8 + import { registerHook } from './hooks.ts' 9 + import { runSetupHandler } from './setup.ts' 10 + 11 + /** 12 + * Scan the server/ directory and register all discovered handlers. 13 + * Setup scripts run immediately (in sorted order). 14 + */ 15 + export async function initServer(serverDir: string): Promise<void> { 16 + if (!existsSync(serverDir)) { 17 + log(`[server] No server/ directory found, skipping`) 18 + return 19 + } 20 + 21 + const scanned = await scanServerDir(serverDir) 22 + 23 + // 1. Run setup scripts first (sorted by name) 24 + for (const entry of scanned.setup.sort((a, b) => a.name.localeCompare(b.name))) { 25 + await runSetupHandler(entry.name, entry.mod.handler) 26 + } 27 + 28 + // 2. Register feeds 29 + for (const entry of scanned.feeds) { 30 + const feedName = entry.name.includes('/') ? entry.name.split('/').pop()! : entry.name 31 + registerFeed(feedName, entry.mod) 32 + } 33 + 34 + // 3. Register XRPC handlers 35 + for (const entry of scanned.queries) { 36 + registerXrpcHandler(entry.mod.nsid, entry.mod) 37 + } 38 + for (const entry of scanned.procedures) { 39 + registerXrpcHandler(entry.mod.nsid, entry.mod) 40 + } 41 + 42 + // 4. Register hooks 43 + for (const entry of scanned.hooks) { 44 + registerHook(entry.mod.event, entry.mod.handler) 45 + } 46 + 47 + // 5. Register labels 48 + for (const entry of scanned.labels) { 49 + registerLabelModule(entry.name, entry.mod) 50 + } 51 + 52 + // 6. Register OG handlers 53 + for (const entry of scanned.og) { 54 + registerOgHandler(entry.mod) 55 + } 56 + 57 + log(`[server] Initialized from server/ directory:`) 58 + log(` Feeds: ${listFeeds().map((f) => f.name).join(', ') || 'none'}`) 59 + log(` XRPC: ${listXrpc().join(', ') || 'none'}`) 60 + log(` Labels: ${getLabelDefinitions().length} definitions`) 61 + }
+16
packages/hatk/src/setup.ts
··· 42 42 } 43 43 } 44 44 45 + export type SetupHandler = (ctx: SetupContext) => Promise<void> 46 + 47 + export function defineSetup(handler: SetupHandler) { 48 + return { __type: 'setup' as const, handler } 49 + } 50 + 45 51 /** Recursively collect .ts/.js files in a directory, skipping files prefixed with `_`. */ 46 52 function walkDir(dir: string): string[] { 47 53 const results: string[] = [] ··· 90 96 log(`[setup] done: ${name}`) 91 97 } 92 98 } 99 + 100 + /** Run a single setup handler with a SetupContext. */ 101 + export async function runSetupHandler(name: string, handler: SetupHandler): Promise<void> { 102 + const ctx: SetupContext = { 103 + db: { query: querySQL, run: runSQL, runBatch, createBulkInserter: createBulkInserterSQL }, 104 + } 105 + log(`[setup] running: ${name}`) 106 + await handler(ctx) 107 + log(`[setup] done: ${name}`) 108 + }
+2 -2
packages/hatk/src/vite-plugin.ts
··· 53 53 { 54 54 test: { 55 55 name: 'unit', 56 - include: ['test/feeds/**/*.test.ts', 'test/xrpc/**/*.test.ts'], 56 + include: ['test/server/**/*.test.ts', 'test/feeds/**/*.test.ts', 'test/xrpc/**/*.test.ts'], 57 57 }, 58 58 }, 59 59 { ··· 69 69 70 70 configureServer(server) { 71 71 const mainPath = resolve(import.meta.dirname!, 'main.js') 72 - const watchDirs = ['xrpc', 'feeds', 'labels', 'jobs', 'setup', 'lexicons'].filter((d) => existsSync(d)) 72 + const watchDirs = ['server', 'xrpc', 'feeds', 'labels', 'jobs', 'setup', 'lexicons'].filter((d) => existsSync(d)) 73 73 const watchArgs = watchDirs.flatMap((d) => ['--watch-path', d]) 74 74 serverProcess = spawn('npx', ['tsx', 'watch', ...watchArgs, mainPath, 'hatk.config.ts'], { 75 75 stdio: 'inherit',
+60
packages/hatk/src/xrpc.ts
··· 233 233 } 234 234 } 235 235 236 + /** Register a single XRPC handler from a scanned server/ module. */ 237 + export function registerXrpcHandler(nsid: string, handlerModule: { handler: (ctx: any) => Promise<any> }): void { 238 + const lexicon = getLexicon(nsid) 239 + const paramsDef = lexicon?.defs?.main?.parameters 240 + const requiredParams: string[] = paramsDef?.required || [] 241 + const paramProperties: Record<string, any> = paramsDef?.properties || {} 242 + 243 + handlers.set(nsid, { 244 + name: nsid, 245 + execute: async (params, cursor, limit, viewer, input) => { 246 + for (const [key, def] of Object.entries(paramProperties)) { 247 + if (params[key] == null && def.default != null) { 248 + params[key] = String(def.default) 249 + } 250 + if (params[key] != null && def.type === 'integer') { 251 + params[key] = Number(params[key]) as any 252 + } 253 + } 254 + for (const param of requiredParams) { 255 + if (!params[param]) { 256 + throw new InvalidRequestError(`Missing required parameter: ${param}`, 'InvalidRequest') 257 + } 258 + } 259 + 260 + const ctx: XrpcContext = { 261 + db: { query: querySQL, run: runSQL }, 262 + params, 263 + input: input || {}, 264 + cursor, 265 + limit, 266 + viewer, 267 + packCursor, 268 + unpackCursor, 269 + isTakendown: isTakendownDid, 270 + filterTakendownDids, 271 + search: searchRecords, 272 + resolve: resolveRecords as any, 273 + lookup: async (collection, field, values) => { 274 + if (values.length === 0) return new Map() 275 + const unique = [...new Set(values.filter(Boolean))] 276 + return lookupByFieldBatch(collection, field, unique) as any 277 + }, 278 + count: async (collection, field, values) => { 279 + if (values.length === 0) return new Map() 280 + const unique = [...new Set(values.filter(Boolean))] 281 + return countByFieldBatch(collection, field, unique) 282 + }, 283 + exists: async (collection, filters) => { 284 + const conditions = Object.entries(filters).map(([field, value]) => ({ field, value })) 285 + const uri = await findUriByFields(collection, conditions) 286 + return uri !== null 287 + }, 288 + labels: queryLabelsForUris, 289 + blobUrl, 290 + } 291 + return handlerModule.handler(ctx) 292 + }, 293 + }) 294 + } 295 + 236 296 /** Execute a registered XRPC handler by name. Returns null if no handler matches. */ 237 297 export async function executeXrpc( 238 298 name: string,