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.

refactor: extract CLI scaffold templates to .tpl files, include handle in 401 responses

Move inline template strings from cli.ts into separate .tpl files under
src/templates/ for readability and maintainability. Update xrpc template
with $hatk import and typed query results. Include viewer handle in
ScopeMissing and proxy error responses so the generated client can
preserve identity across re-authentication redirects.

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

+174 -164
+1 -1
packages/hatk/package.json
··· 28 28 "./renderer": "./dist/renderer.js" 29 29 }, 30 30 "scripts": { 31 - "build": "tsc -p tsconfig.build.json", 31 + "build": "tsc -p tsconfig.build.json && cp -r src/templates dist/templates", 32 32 "prepublishOnly": "npm run build" 33 33 }, 34 34 "dependencies": {
+16 -152
packages/hatk/src/cli.ts
··· 107 107 108 108 // --- Templates --- 109 109 110 - const templates: Record<string, (name: string) => string> = { 111 - feed: (name) => `import { defineFeed } from '../hatk.generated.ts' 112 - 113 - export default defineFeed({ 114 - collection: 'your.collection.here', 115 - label: '${name.charAt(0).toUpperCase() + name.slice(1)}', 116 - 117 - async generate(ctx) { 118 - const { rows, cursor } = await ctx.paginate<{ uri: string }>( 119 - \`SELECT uri, cid, indexed_at FROM "your.collection.here"\`, 120 - ) 121 - 122 - return ctx.ok({ uris: rows.map((r) => r.uri), cursor }) 123 - }, 124 - }) 125 - `, 126 - xrpc: (name) => `import { defineQuery } from '../hatk.generated.ts' 127 - 128 - export default defineQuery('${name}', async (ctx) => { 129 - const { ok, db, params, packCursor, unpackCursor } = ctx 130 - const limit = params.limit ?? 30 131 - const cursor = params.cursor 132 - 133 - const conditions: string[] = [] 134 - const sqlParams: (string | number)[] = [] 135 - let paramIdx = 1 136 - 137 - if (cursor) { 138 - const parsed = unpackCursor(cursor) 139 - if (parsed) { 140 - conditions.push(\`(s.indexed_at < $\${paramIdx} OR (s.indexed_at = $\${paramIdx + 1} AND s.cid < $\${paramIdx + 2}))\`) 141 - sqlParams.push(parsed.primary, parsed.primary, parsed.cid) 142 - paramIdx += 3 143 - } 144 - } 145 - 146 - const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '' 147 - 148 - const rows = await db.query( 149 - \`SELECT s.* FROM "your.collection.here" s \${where} ORDER BY s.indexed_at DESC, s.cid DESC LIMIT $\${paramIdx}\`, 150 - sqlParams.concat([limit + 1]), 151 - ) 152 - 153 - const hasMore = rows.length > limit 154 - if (hasMore) rows.pop() 155 - const lastRow = rows[rows.length - 1] 110 + const templateDir = join(import.meta.dirname!, 'templates') 156 111 157 - return ok({ 158 - items: rows, 159 - cursor: hasMore && lastRow ? packCursor(lastRow.indexed_at, lastRow.cid) : undefined, 160 - }) 161 - }) 162 - `, 163 - label: (name) => `import type { LabelRuleContext } from '@hatk/hatk/labels' 164 - 165 - export default { 166 - definition: { 167 - identifier: '${name}', 168 - severity: 'inform', 169 - blurs: 'none', 170 - defaultSetting: 'warn', 171 - locales: [{ lang: 'en', name: '${name.charAt(0).toUpperCase() + name.slice(1)}', description: 'Description here' }], 172 - }, 173 - async evaluate(ctx: LabelRuleContext) { 174 - // Return array of label identifiers to apply, or empty array 175 - return [] 176 - }, 112 + function loadTemplate(file: string, name: string): string { 113 + const raw = readFileSync(join(templateDir, file), 'utf-8') 114 + const capitalized = name.charAt(0).toUpperCase() + name.slice(1) 115 + return raw.replaceAll('{{name}}', name).replaceAll('{{Name}}', capitalized) 177 116 } 178 - `, 179 - og: (name) => `import type { OpengraphContext, OpengraphResult } from '@hatk/hatk/opengraph' 180 117 181 - export default { 182 - path: '/og/${name}/:id', 183 - async generate(ctx: OpengraphContext): Promise<OpengraphResult> { 184 - const { db, params } = ctx 185 - return { 186 - element: { 187 - type: 'div', 188 - props: { 189 - style: { display: 'flex', width: '100%', height: '100%', background: '#080b12', color: 'white', alignItems: 'center', justifyContent: 'center' }, 190 - children: params.id, 191 - }, 192 - }, 193 - } 194 - }, 195 - } 196 - `, 197 - hook: (name) => `import { defineHook } from '../hatk.generated.ts' 118 + const templateTypes = ['feed', 'xrpc', 'label', 'og', 'hook', 'setup'] as const 119 + const testTemplateTypes = ['feed', 'xrpc'] as const 198 120 199 - export default defineHook('${name}', async (ctx) => { 200 - // Hook logic here 201 - }) 202 - `, 203 - setup: (_name) => `import { defineSetup } from '../hatk.generated.ts' 121 + const templates: Record<string, (name: string) => string> = Object.fromEntries( 122 + templateTypes.map((t) => [t, (name: string) => loadTemplate(`${t}.tpl`, name)]), 123 + ) 204 124 205 - export default defineSetup(async (ctx) => { 206 - // Setup logic here — runs before the server starts 207 - }) 208 - `, 209 - } 210 - 211 - const testTemplates: Record<string, (name: string) => string> = { 212 - feed: (name) => `import { describe, test, expect, beforeAll, afterAll } from 'vitest' 213 - import { createTestContext } from '@hatk/hatk/test' 214 - 215 - let ctx: Awaited<ReturnType<typeof createTestContext>> 216 - 217 - beforeAll(async () => { 218 - ctx = await createTestContext() 219 - await ctx.loadFixtures() 220 - }) 221 - 222 - afterAll(async () => ctx?.close()) 223 - 224 - describe('${name} feed', () => { 225 - test('returns results', async () => { 226 - const feed = ctx.loadFeed('${name}') 227 - const result = await feed.generate(ctx.feedContext({ limit: 10 })) 228 - expect(result).toBeDefined() 229 - }) 230 - }) 231 - `, 232 - xrpc: (name) => `import { describe, test, expect, beforeAll, afterAll } from 'vitest' 233 - import { createTestContext } from '@hatk/hatk/test' 234 - 235 - let ctx: Awaited<ReturnType<typeof createTestContext>> 236 - 237 - beforeAll(async () => { 238 - ctx = await createTestContext() 239 - await ctx.loadFixtures() 240 - }) 241 - 242 - afterAll(async () => ctx?.close()) 243 - 244 - describe('${name}', () => { 245 - test('returns response', async () => { 246 - const handler = ctx.loadXrpc('${name}') 247 - const result = await handler.handler({ params: {} }) 248 - expect(result).toBeDefined() 249 - }) 250 - }) 251 - `, 252 - } 125 + const testTemplates: Record<string, (name: string) => string> = Object.fromEntries( 126 + testTemplateTypes.map((t) => [t, (name: string) => loadTemplate(`test-${t}.tpl`, name)]), 127 + ) 253 128 254 129 const lexiconTemplates: Record<string, (nsid: string) => object> = { 255 130 record: (nsid) => ({ ··· 921 796 922 797 writeFileSync( 923 798 join(dir, 'seeds', 'seed.ts'), 924 - `import { seed } from '../hatk.generated.ts' 925 - 926 - const { createAccount, createRecord } = seed() 927 - 928 - const alice = await createAccount('alice.test') 929 - 930 - // await createRecord(alice, 'your.collection.here', { 931 - // field: 'value', 932 - // }, { rkey: 'my-record' }) 933 - 934 - console.log('\\n[seed] Done!') 935 - `, 799 + loadTemplate('seed.tpl', ''), 936 800 ) 937 801 938 802 writeFileSync( ··· 1843 1707 if (procedureNsids.length > 0) { 1844 1708 clientOut += ` if (_procedures.has(nsid)) {\n` 1845 1709 clientOut += ` const res = await _fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(arg) })\n` 1846 - clientOut += ` if (typeof window !== 'undefined' && res.status === 401) { const _h = getViewer()?.handle; window.location.href = _h ? \`/oauth/login?handle=\${encodeURIComponent(_h)}\` : '/oauth/login'; return new Promise(() => {}) as any }\n` 1710 + clientOut += ` if (typeof window !== 'undefined' && res.status === 401) { const _b = await res.json().catch(() => ({})); const _h = _b.handle ?? getViewer()?.handle; window.location.href = _h ? \`/oauth/login?handle=\${encodeURIComponent(_h)}\` : '/oauth/login'; return new Promise(() => {}) as any }\n` 1847 1711 clientOut += ` if (!res.ok) throw new Error(\`XRPC \${nsid} failed: \${res.status}\`)\n` 1848 1712 clientOut += ` return res.json() as Promise<OutputOf<K>>\n` 1849 1713 clientOut += ` }\n` ··· 1855 1719 clientOut += ` const qs = params.toString()\n` 1856 1720 clientOut += ` if (qs) path += \`?\${qs}\`\n` 1857 1721 clientOut += ` const res = await _fetch(path)\n` 1858 - clientOut += ` if (typeof window !== 'undefined' && res.status === 401) { window.location.href = '/oauth/login'; return new Promise(() => {}) as any }\n` 1722 + clientOut += ` if (typeof window !== 'undefined' && res.status === 401) { const _b = await res.json().catch(() => ({})); const _h = _b.handle ?? getViewer()?.handle; window.location.href = _h ? \`/oauth/login?handle=\${encodeURIComponent(_h)}\` : '/oauth/login'; return new Promise(() => {}) as any }\n` 1859 1723 clientOut += ` if (!res.ok) throw new Error(\`XRPC \${nsid} failed: \${res.status}\`)\n` 1860 1724 clientOut += ` return res.json() as Promise<OutputOf<K>>\n` 1861 1725 clientOut += `}\n`
+11 -11
packages/hatk/src/server.ts
··· 64 64 import { serve } from './adapter.ts' 65 65 import { renderPage } from './renderer.ts' 66 66 67 - function scopeMissingResponse(acceptEncoding: string | null): Response { 68 - const res = withCors(jsonError(401, 'ScopeMissingError', acceptEncoding)) 67 + function scopeMissingResponse(acceptEncoding: string | null, handle?: string): Response { 68 + const res = withCors(json({ error: 'ScopeMissingError', ...(handle ? { handle } : {}) }, 401, acceptEncoding)) 69 69 res.headers.append('Set-Cookie', clearSessionCookieHeader()) 70 70 return res 71 71 } ··· 836 836 const result = await pdsCreateRecord(oauth, viewer, body) 837 837 return withCors(json(result, 200, acceptEncoding)) 838 838 } catch (err: any) { 839 - if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding) 840 - if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 839 + if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding, viewer?.handle) 840 + if (err instanceof ProxyError) return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding)) 841 841 throw err 842 842 } 843 843 } ··· 850 850 const result = await pdsDeleteRecord(oauth, viewer, body) 851 851 return withCors(json(result, 200, acceptEncoding)) 852 852 } catch (err: any) { 853 - if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding) 854 - if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 853 + if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding, viewer?.handle) 854 + if (err instanceof ProxyError) return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding)) 855 855 throw err 856 856 } 857 857 } ··· 864 864 const result = await pdsPutRecord(oauth, viewer, body) 865 865 return withCors(json(result, 200, acceptEncoding)) 866 866 } catch (err: any) { 867 - if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding) 868 - if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 867 + if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding, viewer?.handle) 868 + if (err instanceof ProxyError) return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding)) 869 869 throw err 870 870 } 871 871 } ··· 879 879 const result = await pdsUploadBlob(oauth, viewer, rawBody, contentType) 880 880 return withCors(json(result, 200, acceptEncoding)) 881 881 } catch (err: any) { 882 - if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding) 883 - if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 882 + if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding, viewer?.handle) 883 + if (err instanceof ProxyError) return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding)) 884 884 throw err 885 885 } 886 886 } ··· 943 943 const result = await executeXrpc(method, params, cursor, limit, viewer, input) 944 944 if (result) return withCors(json(result, 200, acceptEncoding)) 945 945 } catch (err: any) { 946 - if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding) 946 + if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding, viewer?.handle) 947 947 if (err instanceof InvalidRequestError) { 948 948 return withCors(jsonError(err.status, err.errorName || err.message, acceptEncoding)) 949 949 }
+14
packages/hatk/src/templates/feed.tpl
··· 1 + import { defineFeed } from '$hatk' 2 + 3 + export default defineFeed({ 4 + collection: 'your.collection.here', 5 + label: '{{Name}}', 6 + 7 + async generate(ctx) { 8 + const { rows, cursor } = await ctx.paginate<{ uri: string }>( 9 + `SELECT uri, cid, indexed_at FROM "your.collection.here"`, 10 + ) 11 + 12 + return ctx.ok({ uris: rows.map((r) => r.uri), cursor }) 13 + }, 14 + })
+5
packages/hatk/src/templates/hook.tpl
··· 1 + import { defineHook } from '$hatk' 2 + 3 + export default defineHook('{{name}}', async (ctx) => { 4 + // Hook logic here 5 + })
+15
packages/hatk/src/templates/label.tpl
··· 1 + import type { LabelRuleContext } from '@hatk/hatk/labels' 2 + 3 + export default { 4 + definition: { 5 + identifier: '{{name}}', 6 + severity: 'inform', 7 + blurs: 'none', 8 + defaultSetting: 'warn', 9 + locales: [{ lang: 'en', name: '{{Name}}', description: 'Description here' }], 10 + }, 11 + async evaluate(ctx: LabelRuleContext) { 12 + // Return array of label identifiers to apply, or empty array 13 + return [] 14 + }, 15 + }
+17
packages/hatk/src/templates/og.tpl
··· 1 + import type { OpengraphContext, OpengraphResult } from '@hatk/hatk/opengraph' 2 + 3 + export default { 4 + path: '/og/{{name}}/:id', 5 + async generate(ctx: OpengraphContext): Promise<OpengraphResult> { 6 + const { db, params } = ctx 7 + return { 8 + element: { 9 + type: 'div', 10 + props: { 11 + style: { display: 'flex', width: '100%', height: '100%', background: '#080b12', color: 'white', alignItems: 'center', justifyContent: 'center' }, 12 + children: params.id, 13 + }, 14 + }, 15 + } 16 + }, 17 + }
+11
packages/hatk/src/templates/seed.tpl
··· 1 + import { seed } from '$hatk' 2 + 3 + const { createAccount, createRecord } = seed() 4 + 5 + const alice = await createAccount('alice.test') 6 + 7 + // await createRecord(alice, 'your.collection.here', { 8 + // field: 'value', 9 + // }, { rkey: 'my-record' }) 10 + 11 + console.log('\n[seed] Done!')
+5
packages/hatk/src/templates/setup.tpl
··· 1 + import { defineSetup } from '$hatk' 2 + 3 + export default defineSetup(async (ctx) => { 4 + // Setup logic here — runs before the server starts 5 + })
+19
packages/hatk/src/templates/test-feed.tpl
··· 1 + import { describe, test, expect, beforeAll, afterAll } from 'vitest' 2 + import { createTestContext } from '@hatk/hatk/test' 3 + 4 + let ctx: Awaited<ReturnType<typeof createTestContext>> 5 + 6 + beforeAll(async () => { 7 + ctx = await createTestContext() 8 + await ctx.loadFixtures() 9 + }) 10 + 11 + afterAll(async () => ctx?.close()) 12 + 13 + describe('{{name}} feed', () => { 14 + test('returns results', async () => { 15 + const feed = ctx.loadFeed('{{name}}') 16 + const result = await feed.generate(ctx.feedContext({ limit: 10 })) 17 + expect(result).toBeDefined() 18 + }) 19 + })
+19
packages/hatk/src/templates/test-xrpc.tpl
··· 1 + import { describe, test, expect, beforeAll, afterAll } from 'vitest' 2 + import { createTestContext } from '@hatk/hatk/test' 3 + 4 + let ctx: Awaited<ReturnType<typeof createTestContext>> 5 + 6 + beforeAll(async () => { 7 + ctx = await createTestContext() 8 + await ctx.loadFixtures() 9 + }) 10 + 11 + afterAll(async () => ctx?.close()) 12 + 13 + describe('{{name}}', () => { 14 + test('returns response', async () => { 15 + const handler = ctx.loadXrpc('{{name}}') 16 + const result = await handler.handler({ params: {} }) 17 + expect(result).toBeDefined() 18 + }) 19 + })
+41
packages/hatk/src/templates/xrpc.tpl
··· 1 + import { defineQuery } from '$hatk' 2 + 3 + export default defineQuery('{{name}}', async (ctx) => { 4 + const { ok, db, params, packCursor, unpackCursor } = ctx 5 + const limit = params.limit ?? 30 6 + const cursor = params.cursor 7 + 8 + const conditions: string[] = [] 9 + const sqlParams: (string | number)[] = [] 10 + let paramIdx = 1 11 + 12 + if (cursor) { 13 + const parsed = unpackCursor(cursor) 14 + if (parsed) { 15 + conditions.push(`(s.indexed_at < $${paramIdx} OR (s.indexed_at = $${paramIdx + 1} AND s.cid < $${paramIdx + 2}))`) 16 + sqlParams.push(parsed.primary, parsed.primary, parsed.cid) 17 + paramIdx += 3 18 + } 19 + } 20 + 21 + const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '' 22 + 23 + const rows = (await db.query( 24 + `SELECT s.* FROM "your.collection.here" s ${where} ORDER BY s.indexed_at DESC, s.cid DESC LIMIT $${paramIdx}`, 25 + sqlParams.concat([limit + 1]), 26 + )) as { 27 + uri: string 28 + cid: string 29 + did: string 30 + indexed_at: string 31 + }[] 32 + 33 + const hasMore = rows.length > limit 34 + if (hasMore) rows.pop() 35 + const lastRow = rows[rows.length - 1] 36 + 37 + return ok({ 38 + items: rows, 39 + cursor: hasMore && lastRow ? packCursor(lastRow.indexed_at, lastRow.cid) : undefined, 40 + }) 41 + })