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

Configure Feed

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

feat: replace config.yaml with hatk.config.ts for type-safe configuration

Add defineConfig() identity function for type inference. Rewrite loadConfig
to use dynamic import() instead of YAML parsing. Update all call sites
(main.ts, cli.ts, test.ts, vite-plugin.ts) and the scaffolder. Add
./config package export. Dockerfile template uses --experimental-strip-types
for native Node 25 TS support.

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

+87 -57
+33 -26
docs/site/src/content/docs/getting-started/configuration.mdx
··· 1 1 --- 2 2 title: Configuration 3 - description: Configure your Hatk project with config.yaml. 3 + description: Configure your Hatk project with hatk.config.ts. 4 4 --- 5 5 6 6 ## Overview 7 7 8 - Hatk is configured through `config.yaml` at the project root. Most options can be overridden with environment variables. 8 + Hatk is configured through `hatk.config.ts` at the project root. The `defineConfig` helper provides type safety and autocompletion. Most options can be overridden with environment variables. 9 9 10 10 ## Complete example 11 11 12 - ```yaml 13 - relay: ws://localhost:2583 14 - plc: http://localhost:2582 15 - port: 3000 16 - database: data/hatk.db 17 - public: ./public 18 - admins: [] 12 + ```typescript 13 + import { defineConfig } from '@hatk/hatk/config' 19 14 20 - backfill: 21 - parallelism: 10 22 - fetchTimeout: 300 23 - maxRetries: 5 24 - fullNetwork: false 15 + export default defineConfig({ 16 + relay: 'ws://localhost:2583', 17 + plc: 'http://localhost:2582', 18 + port: 3000, 19 + database: 'data/hatk.db', 20 + publicDir: './public', 21 + admins: [], 22 + 23 + backfill: { 24 + parallelism: 10, 25 + fetchTimeout: 300, 26 + maxRetries: 5, 27 + fullNetwork: false, 28 + }, 25 29 26 - ftsRebuildInterval: 500 30 + ftsRebuildInterval: 500, 27 31 28 - oauth: 29 - issuer: http://127.0.0.1:3000 30 - scopes: 31 - - atproto 32 - clients: 33 - - client_id: http://localhost 34 - client_name: My App 35 - redirect_uris: 36 - - http://127.0.0.1:3000/oauth/callback 32 + oauth: { 33 + issuer: 'http://127.0.0.1:3000', 34 + scopes: ['atproto'], 35 + clients: [ 36 + { 37 + client_id: 'http://localhost', 38 + client_name: 'My App', 39 + redirect_uris: ['http://127.0.0.1:3000/oauth/callback'], 40 + }, 41 + ], 42 + }, 43 + }) 37 44 ``` 38 45 39 46 ## Options ··· 68 75 - **Default:** `:memory:` 69 76 - **Env:** `DATABASE` 70 77 71 - ### `public` 78 + ### `publicDir` 72 79 73 - Directory for static files. Set to `false` to disable static file serving. 80 + Directory for static files. Set to `null` to disable static file serving. 74 81 75 82 - **Default:** `./public` 76 83
+2 -1
packages/hatk/package.json
··· 1 1 { 2 2 "name": "@hatk/hatk", 3 - "version": "0.0.1-alpha.19", 3 + "version": "0.0.1-alpha.20", 4 4 "license": "MIT", 5 5 "bin": { 6 6 "hatk": "dist/cli.js" ··· 23 23 "./setup": "./dist/setup.js", 24 24 "./test": "./dist/test.js", 25 25 "./test/browser": "./dist/test-browser.js", 26 + "./config": "./dist/config.js", 26 27 "./vite-plugin": "./dist/vite-plugin.js" 27 28 }, 28 29 "scripts": {
+1 -1
packages/hatk/src/backfill.ts
··· 25 25 plcUrl: string 26 26 /** AT Protocol collection NSIDs to index (e.g. `app.bsky.feed.post`). */ 27 27 collections: Set<string> 28 - /** Backfill behavior settings from `config.yaml`. */ 28 + /** Backfill behavior settings from `hatk.config.ts`. */ 29 29 config: BackfillConfig 30 30 } 31 31
+18 -14
packages/hatk/src/cli.ts
··· 375 375 } 376 376 377 377 writeFileSync( 378 - join(dir, 'config.yaml'), 379 - `relay: ws://localhost:2583 380 - plc: http://localhost:2582 381 - port: 3000 382 - database: data/hatk.db 383 - admins: [] 378 + join(dir, 'hatk.config.ts'), 379 + `import { defineConfig } from '@hatk/hatk/config' 384 380 385 - backfill: 386 - parallelism: 10 381 + export default defineConfig({ 382 + relay: 'ws://localhost:2583', 383 + plc: 'http://localhost:2582', 384 + port: 3000, 385 + database: 'data/hatk.db', 386 + admins: [], 387 + backfill: { 388 + parallelism: 10, 389 + }, 390 + }) 387 391 `, 388 392 ) 389 393 ··· 985 989 RUN node_modules/.bin/hatk build 986 990 RUN npm prune --omit=dev 987 991 EXPOSE 3000 988 - CMD ["node", "--max-old-space-size=512", "node_modules/@hatk/hatk/dist/main.js", "config.yaml"] 992 + CMD ["node", "--experimental-strip-types", "--max-old-space-size=512", "node_modules/@hatk/hatk/dist/main.js", "hatk.config.ts"] 989 993 `, 990 994 ) 991 995 ··· 1294 1298 } 1295 1299 1296 1300 console.log(`Created ${name}/`) 1297 - console.log(` config.yaml`) 1301 + console.log(` hatk.config.ts`) 1298 1302 console.log(` lexicons/ — lexicon JSON files (core + your own)`) 1299 1303 console.log(` feeds/ — feed generators`) 1300 1304 console.log(` xrpc/ — XRPC method handlers`) ··· 1737 1741 } else { 1738 1742 // No frontend — just run the hatk server directly 1739 1743 const mainPath = resolve(import.meta.dirname!, 'main.js') 1740 - execSync(`npx tsx ${mainPath} config.yaml`, { 1744 + execSync(`npx tsx ${mainPath} hatk.config.ts`, { 1741 1745 stdio: 'inherit', 1742 1746 cwd: process.cwd(), 1743 1747 env: { ...process.env, DEV_MODE: '1' }, ··· 1760 1764 console.log('[build] No frontend to build (API-only hatk)') 1761 1765 } 1762 1766 } else if (command === 'reset') { 1763 - const config = loadConfig(resolve('config.yaml')) 1767 + const config = await loadConfig(resolve('hatk.config.ts')) 1764 1768 1765 1769 if (config.database !== ':memory:') { 1766 1770 for (const suffix of ['', '.wal']) { ··· 1922 1926 console.log(`\nResolved ${resolved.size} lexicon(s). Regenerating types...`) 1923 1927 execSync('npx hatk generate types', { stdio: 'inherit', cwd: process.cwd() }) 1924 1928 } else if (command === 'schema') { 1925 - const config = loadConfig(resolve('config.yaml')) 1929 + const config = await loadConfig(resolve('hatk.config.ts')) 1926 1930 if (config.database === ':memory:') { 1927 1931 console.error('No database file configured (database is :memory:)') 1928 1932 process.exit(1) ··· 1960 1964 } else if (command === 'start') { 1961 1965 try { 1962 1966 const mainPath = resolve(import.meta.dirname!, 'main.js') 1963 - execSync(`npx tsx ${mainPath} config.yaml`, { stdio: 'inherit', cwd: process.cwd() }) 1967 + execSync(`npx tsx ${mainPath} hatk.config.ts`, { stdio: 'inherit', cwd: process.cwd() }) 1964 1968 } catch (e: any) { 1965 1969 if (e.signal === 'SIGINT' || e.signal === 'SIGTERM') process.exit(0) 1966 1970 throw e
+26 -8
packages/hatk/src/config.ts
··· 1 - import { readFileSync } from 'node:fs' 2 1 import { resolve, dirname } from 'node:path' 3 - import YAML from 'yaml' 2 + import { existsSync } from 'node:fs' 4 3 5 4 export interface LabelLocale { 6 5 lang: string ··· 51 50 admins: string[] // DIDs allowed to access /admin/* endpoints 52 51 } 53 52 53 + /** Identity function that provides type inference for hatk config files. */ 54 + export function defineConfig(config: Partial<HatkConfig>): Partial<HatkConfig> { 55 + return config 56 + } 57 + 54 58 /** Derive HTTP URL from relay WebSocket URL (ws://host → http://host) */ 55 59 export function relayHttpUrl(relay: string): string { 56 60 return relay.replace(/^ws(s?):\/\//, 'http$1://') 57 61 } 58 62 59 - export function loadConfig(configPath: string): HatkConfig { 60 - const raw = readFileSync(configPath, 'utf-8') 61 - const parsed = YAML.parse(raw) 63 + export async function loadConfig(configPath: string): Promise<HatkConfig> { 64 + const resolved = resolve(configPath) 62 65 63 - const configDir = dirname(resolve(configPath)) 66 + if (!existsSync(resolved)) { 67 + console.error(`Config file not found: ${resolved}`) 68 + console.error(`hatk now uses hatk.config.ts instead of config.yaml.`) 69 + console.error(`Create a hatk.config.ts file or run 'hatk new' to scaffold a project.`) 70 + process.exit(1) 71 + } 64 72 65 - const backfillRaw = parsed.backfill || {} 73 + const configDir = dirname(resolved) 74 + let mod: any 75 + try { 76 + mod = await import(resolved) 77 + } catch (err: any) { 78 + console.error(`Failed to load config file: ${resolved}`) 79 + console.error(err.message || err) 80 + process.exit(1) 81 + } 82 + const parsed: Partial<HatkConfig> & Record<string, any> = mod.default || {} 66 83 84 + const backfillRaw = parsed.backfill || ({} as Partial<BackfillConfig>) 67 85 const env = process.env 68 86 69 87 const database = env.DATABASE || parsed.database ··· 72 90 plc: env.DID_PLC_URL || parsed.plc || 'https://plc.directory', 73 91 port: parseInt(env.PORT || '') || parsed.port || 3000, 74 92 database: database ? resolve(configDir, database) : ':memory:', 75 - publicDir: parsed.public === false ? null : resolve(configDir, parsed.public || './public'), 93 + publicDir: parsed.publicDir === null ? null : resolve(configDir, parsed.publicDir || './public'), 76 94 collections: parsed.collections || [], 77 95 backfill: { 78 96 signalCollections: backfillRaw.signalCollections || undefined,
+2 -2
packages/hatk/src/main.ts
··· 31 31 log(`[mem] ${phase}: heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB rss=${Math.round(mem.rss / 1024 / 1024)}MB external=${Math.round(mem.external / 1024 / 1024)}MB arrayBuffers=${Math.round(mem.arrayBuffers / 1024 / 1024)}MB`) 32 32 } 33 33 34 - const configPath = process.argv[2] || 'config.yaml' 34 + const configPath = process.argv[2] || 'hatk.config.ts' 35 35 const configDir = dirname(resolve(configPath)) 36 36 37 37 logMemory('startup') 38 38 39 39 // 1. Load config 40 - const config = loadConfig(configPath) 40 + const config = await loadConfig(configPath) 41 41 configureRelay(config.relay) 42 42 43 43 // 2. Load lexicons, validate schemas, and discover collections
+4 -4
packages/hatk/src/test.ts
··· 51 51 } 52 52 53 53 /** 54 - * Find the project's config.yaml by walking up from cwd. 55 - * Returns the resolved config path, or falls back to 'config.yaml'. 54 + * Find the project's hatk.config.ts by walking up from cwd. 55 + * Returns the resolved config path, or falls back to 'hatk.config.ts'. 56 56 */ 57 57 function findConfigPath(): string { 58 58 const explicit = process.env.APPVIEW_CONFIG 59 59 if (explicit) return resolve(explicit) 60 - return resolve('config.yaml') 60 + return resolve('hatk.config.ts') 61 61 } 62 62 63 63 /** ··· 71 71 */ 72 72 export async function createTestContext(): Promise<TestContext> { 73 73 const configPath = findConfigPath() 74 - const config = loadConfig(configPath) 74 + const config = await loadConfig(configPath) 75 75 const configDir = dirname(resolve(configPath)) 76 76 77 77 configureRelay(config.relay)
+1 -1
packages/hatk/src/vite-plugin.ts
··· 68 68 const mainPath = resolve(import.meta.dirname!, 'main.js') 69 69 const watchDirs = ['xrpc', 'feeds', 'labels', 'jobs', 'setup', 'lexicons'].filter((d) => existsSync(d)) 70 70 const watchArgs = watchDirs.flatMap((d) => ['--watch-path', d]) 71 - serverProcess = spawn('npx', ['tsx', 'watch', ...watchArgs, mainPath, 'config.yaml'], { 71 + serverProcess = spawn('npx', ['tsx', 'watch', ...watchArgs, mainPath, 'hatk.config.ts'], { 72 72 stdio: 'inherit', 73 73 cwd: process.cwd(), 74 74 env: {