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.

fix: address code review findings for server-directory refactor

- Guard __dev/login with oauth check to prevent crash when OAuth not configured
- Fix sendResponse to preserve duplicate Set-Cookie headers (flat array format)
- Remove misleading status_code: 0 from telemetry emit
- Stabilize HMAC key derivation with sorted JSON keys
- Log PDS proxy local index failures via emit() instead of silent catch
- Extract shared withDpopRetry() to deduplicate proxy retry logic
- Share isHatkRoute() between adapter.ts and vite-plugin.ts
- Fix hasRenderer to use live function call instead of boot-time const
- Support configurable cookie name in generated client code
- Remove any types from pds-proxy.ts

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

+92 -107
+7 -3
packages/hatk/src/adapter.ts
··· 35 35 * Pipe a Web Standard Response back to a Node.js ServerResponse. 36 36 */ 37 37 export async function sendResponse(res: ServerResponse, response: Response): Promise<void> { 38 - res.writeHead(response.status, Object.fromEntries(response.headers.entries())) 38 + const rawHeaders: string[] = [] 39 + response.headers.forEach((value, name) => { 40 + rawHeaders.push(name, value) 41 + }) 42 + res.writeHead(response.status, rawHeaders) 39 43 40 44 if (!response.body) { 41 45 res.end() ··· 56 60 } 57 61 58 62 /** Routes handled by hatk — everything else can fall through to a framework handler. */ 59 - const HATK_ROUTES = ['/xrpc/', '/oauth/', '/.well-known/', '/og/', '/admin', '/repos', '/info/', '/_health', '/robots.txt', '/auth/logout'] 63 + export const HATK_ROUTES = ['/xrpc/', '/oauth/', '/.well-known/', '/og/', '/admin', '/repos', '/info/', '/_health', '/robots.txt', '/auth/logout', '/__dev/'] 60 64 61 - function isHatkRoute(pathname: string): boolean { 65 + export function isHatkRoute(pathname: string): boolean { 62 66 return HATK_ROUTES.some(r => pathname.startsWith(r) || pathname === r) 63 67 } 64 68
+3 -2
packages/hatk/src/cli.ts
··· 1825 1825 clientOut += ` if (parse) {\n` 1826 1826 clientOut += ` const { getRequestEvent } = await import('$app/server')\n` 1827 1827 clientOut += ` const event = getRequestEvent()\n` 1828 - clientOut += ` const cookieValue = event.cookies.get('__hatk_session')\n` 1828 + clientOut += ` const cookieName = (globalThis as any).__hatk_sessionCookieName ?? '__hatk_session'\n` 1829 + clientOut += ` const cookieValue = event.cookies.get(cookieName)\n` 1829 1830 clientOut += ` if (cookieValue) {\n` 1830 1831 clientOut += ` const request = new Request('http://localhost', {\n` 1831 - clientOut += ` headers: { cookie: \`__hatk_session=\${cookieValue}\` },\n` 1832 + clientOut += ` headers: { cookie: \`\${cookieName}=\${cookieValue}\` },\n` 1832 1833 clientOut += ` })\n` 1833 1834 clientOut += ` return parse(request)\n` 1834 1835 clientOut += ` }\n`
+1 -1
packages/hatk/src/dev-entry.ts
··· 119 119 export { renderPage } from './renderer.ts' 120 120 export { getRenderer } from './renderer.ts' 121 121 export { callXrpc } from './xrpc.ts' 122 - export { parseSessionCookie } from './oauth/session.ts' 122 + export { parseSessionCookie, getSessionCookieName } from './oauth/session.ts' 123 123 124 124 log(`[hatk] Dev server ready`) 125 125 log(` Relay: ${config.relay}`)
+5 -1
packages/hatk/src/oauth/session.ts
··· 7 7 let _cookieName = '__hatk_session' 8 8 const MAX_AGE = 30 * 24 * 60 * 60 // 30 days in seconds 9 9 10 + export function getSessionCookieName(): string { 11 + return _cookieName 12 + } 13 + 10 14 export function initSession(privateJwk: JsonWebKey, cookieName?: string): void { 11 15 _privateJwk = privateJwk 12 16 if (cookieName) _cookieName = cookieName ··· 15 19 async function hmacKey(usage: 'sign' | 'verify'): Promise<CryptoKey> { 16 20 return crypto.subtle.importKey( 17 21 'raw', 18 - new TextEncoder().encode(JSON.stringify(_privateJwk)), 22 + new TextEncoder().encode(JSON.stringify(_privateJwk, Object.keys(_privateJwk).sort())), 19 23 { name: 'HMAC', hash: 'SHA-256' }, 20 24 false, 21 25 [usage],
+63 -77
packages/hatk/src/pds-proxy.ts
··· 7 7 import { validateRecord } from '@bigmoves/lexicon' 8 8 import { getLexiconArray } from './database/schema.ts' 9 9 import { insertRecord, deleteRecord as dbDeleteRecord } from './database/db.ts' 10 + import { emit } from './logger.ts' 10 11 11 12 export class ProxyError extends Error { 12 13 constructor(public status: number, message: string) { ··· 16 17 17 18 // --- Low-level PDS proxy with DPoP + nonce retry + token refresh --- 18 19 19 - async function proxyToPds( 20 - oauthConfig: OAuthConfig, 21 - session: any, 22 - method: string, 23 - pdsUrl: string, 24 - body: any, 25 - ): Promise<{ ok: boolean; status: number; body: any; headers: Headers }> { 26 - const serverKey = await getServerKey('appview-oauth-key') 27 - const privateJwk = JSON.parse(serverKey!.privateKey) 28 - const publicJwk = JSON.parse(serverKey!.publicKey) 20 + interface PdsProxyResult { 21 + ok: boolean 22 + status: number 23 + body: Record<string, unknown> 24 + headers: Headers 25 + } 29 26 30 - let accessToken = session.access_token 27 + type FetchFn = (token: string, nonce?: string) => Promise<PdsProxyResult> 31 28 32 - async function doFetch( 33 - token: string, 34 - nonce?: string, 35 - ): Promise<{ ok: boolean; status: number; body: any; headers: Headers }> { 36 - const proof = await createDpopProof(privateJwk, publicJwk, method, pdsUrl, token, nonce) 37 - const res = await fetch(pdsUrl, { 38 - method, 39 - headers: { 40 - 'Content-Type': 'application/json', 41 - Authorization: `DPoP ${token}`, 42 - DPoP: proof, 43 - }, 44 - body: JSON.stringify(body), 45 - }) 46 - const resBody = await res.json().catch(() => ({})) 47 - return { ok: res.ok, status: res.status, body: resBody, headers: res.headers } 48 - } 29 + /** Shared retry logic: DPoP nonce handling + token refresh. */ 30 + async function withDpopRetry( 31 + oauthConfig: OAuthConfig, 32 + session: { access_token: string; pds_endpoint: string; did: string; refresh_token: string; dpop_jkt: string }, 33 + doFetch: FetchFn, 34 + ): Promise<PdsProxyResult> { 35 + let accessToken = session.access_token 49 36 50 37 let result = await doFetch(accessToken) 51 38 if (result.ok) return result ··· 68 55 accessToken = refreshed.accessToken 69 56 result = await doFetch(accessToken, nonce) 70 57 if (result.ok) return result 71 - // May need DPoP nonce after refresh 72 58 if (result.body.error === 'use_dpop_nonce') { 73 59 nonce = result.headers.get('DPoP-Nonce') || undefined 74 60 if (nonce) result = await doFetch(accessToken, nonce) ··· 79 65 return result 80 66 } 81 67 68 + async function proxyToPds( 69 + oauthConfig: OAuthConfig, 70 + session: { access_token: string; pds_endpoint: string; did: string; refresh_token: string; dpop_jkt: string }, 71 + method: string, 72 + pdsUrl: string, 73 + body: unknown, 74 + ): Promise<PdsProxyResult> { 75 + const serverKey = await getServerKey('appview-oauth-key') 76 + const privateJwk = JSON.parse(serverKey!.privateKey) 77 + const publicJwk = JSON.parse(serverKey!.publicKey) 78 + 79 + return withDpopRetry(oauthConfig, session, async (token, nonce) => { 80 + const proof = await createDpopProof(privateJwk, publicJwk, method, pdsUrl, token, nonce) 81 + const res = await fetch(pdsUrl, { 82 + method, 83 + headers: { 84 + 'Content-Type': 'application/json', 85 + Authorization: `DPoP ${token}`, 86 + DPoP: proof, 87 + }, 88 + body: JSON.stringify(body), 89 + }) 90 + const resBody: Record<string, unknown> = await res.json().catch(() => ({})) 91 + return { ok: res.ok, status: res.status, body: resBody, headers: res.headers } 92 + }) 93 + } 94 + 82 95 /** Proxy a raw binary request to the user's PDS with DPoP + nonce retry + token refresh. */ 83 96 async function proxyToPdsRaw( 84 97 oauthConfig: OAuthConfig, ··· 86 99 pdsUrl: string, 87 100 body: Uint8Array, 88 101 contentType: string, 89 - ): Promise<{ ok: boolean; status: number; body: Record<string, unknown>; headers: Headers }> { 102 + ): Promise<PdsProxyResult> { 90 103 const serverKey = await getServerKey('appview-oauth-key') 91 104 const privateJwk = JSON.parse(serverKey!.privateKey) 92 105 const publicJwk = JSON.parse(serverKey!.publicKey) 93 106 94 - let accessToken = session.access_token 95 - 96 - async function doFetch( 97 - token: string, 98 - nonce?: string, 99 - ): Promise<{ ok: boolean; status: number; body: Record<string, unknown>; headers: Headers }> { 107 + return withDpopRetry(oauthConfig, session, async (token, nonce) => { 100 108 const proof = await createDpopProof(privateJwk, publicJwk, 'POST', pdsUrl, token, nonce) 101 109 const res = await fetch(pdsUrl, { 102 110 method: 'POST', ··· 110 118 }) 111 119 const resBody: Record<string, unknown> = await res.json().catch(() => ({})) 112 120 return { ok: res.ok, status: res.status, body: resBody, headers: res.headers } 113 - } 114 - 115 - let result = await doFetch(accessToken) 116 - if (result.ok) return result 117 - 118 - let nonce: string | undefined 119 - 120 - if (result.body.error === 'use_dpop_nonce') { 121 - nonce = result.headers.get('DPoP-Nonce') || undefined 122 - if (nonce) { 123 - result = await doFetch(accessToken, nonce) 124 - if (result.ok) return result 125 - } 126 - } 127 - 128 - if (result.body.error === 'invalid_token') { 129 - const refreshed = await refreshPdsSession(oauthConfig, session) 130 - if (refreshed) { 131 - accessToken = refreshed.accessToken 132 - result = await doFetch(accessToken, nonce) 133 - if (result.ok) return result 134 - if (result.body.error === 'use_dpop_nonce') { 135 - nonce = result.headers.get('DPoP-Nonce') || undefined 136 - if (nonce) result = await doFetch(accessToken, nonce) 137 - } 138 - } 139 - } 140 - 141 - return result 121 + }) 142 122 } 143 123 144 124 // --- High-level proxy functions --- ··· 146 126 export async function pdsCreateRecord( 147 127 oauthConfig: OAuthConfig, 148 128 viewer: { did: string }, 149 - input: { collection: string; repo?: string; rkey?: string; record: Record<string, any> }, 129 + input: { collection: string; repo?: string; rkey?: string; record: Record<string, unknown> }, 150 130 ): Promise<{ uri?: string; cid?: string }> { 151 131 const validationError = validateRecord(getLexiconArray(), input.collection, input.record) 152 132 if (validationError) { ··· 165 145 } 166 146 167 147 const pdsRes = await proxyToPds(oauthConfig, session, 'POST', pdsUrl, pdsBody) 168 - if (!pdsRes.ok) throw new ProxyError(pdsRes.status, pdsRes.body.error || 'PDS write failed') 148 + if (!pdsRes.ok) throw new ProxyError(pdsRes.status, String(pdsRes.body.error || 'PDS write failed')) 169 149 170 150 try { 171 - await insertRecord(input.collection, pdsRes.body.uri, pdsRes.body.cid, viewer.did, input.record) 172 - } catch {} 151 + await insertRecord(input.collection, String(pdsRes.body.uri), String(pdsRes.body.cid), viewer.did, input.record) 152 + } catch (err: unknown) { 153 + emit('pds-proxy', 'local_index_error', { op: 'createRecord', error: err instanceof Error ? err.message : String(err) }) 154 + } 173 155 174 - return pdsRes.body 156 + return pdsRes.body as { uri?: string; cid?: string } 175 157 } 176 158 177 159 export async function pdsDeleteRecord( ··· 190 172 } 191 173 192 174 const pdsRes = await proxyToPds(oauthConfig, session, 'POST', pdsUrl, pdsBody) 193 - if (!pdsRes.ok) throw new ProxyError(pdsRes.status, pdsRes.body.error || 'PDS delete failed') 175 + if (!pdsRes.ok) throw new ProxyError(pdsRes.status, String(pdsRes.body.error || 'PDS delete failed')) 194 176 195 177 try { 196 178 const uri = `at://${viewer.did}/${input.collection}/${input.rkey}` 197 179 await dbDeleteRecord(input.collection, uri) 198 - } catch {} 180 + } catch (err: unknown) { 181 + emit('pds-proxy', 'local_index_error', { op: 'deleteRecord', error: err instanceof Error ? err.message : String(err) }) 182 + } 199 183 200 184 return pdsRes.body 201 185 } ··· 203 187 export async function pdsPutRecord( 204 188 oauthConfig: OAuthConfig, 205 189 viewer: { did: string }, 206 - input: { collection: string; rkey: string; record: Record<string, any>; repo?: string }, 190 + input: { collection: string; rkey: string; record: Record<string, unknown>; repo?: string }, 207 191 ): Promise<{ uri?: string; cid?: string }> { 208 192 const validationError = validateRecord(getLexiconArray(), input.collection, input.record) 209 193 if (validationError) { ··· 222 206 } 223 207 224 208 const pdsRes = await proxyToPds(oauthConfig, session, 'POST', pdsUrl, pdsBody) 225 - if (!pdsRes.ok) throw new ProxyError(pdsRes.status, pdsRes.body.error || 'PDS write failed') 209 + if (!pdsRes.ok) throw new ProxyError(pdsRes.status, String(pdsRes.body.error || 'PDS write failed')) 226 210 227 211 try { 228 - await insertRecord(input.collection, pdsRes.body.uri, pdsRes.body.cid, viewer.did, input.record) 229 - } catch {} 212 + await insertRecord(input.collection, String(pdsRes.body.uri), String(pdsRes.body.cid), viewer.did, input.record) 213 + } catch (err: unknown) { 214 + emit('pds-proxy', 'local_index_error', { op: 'putRecord', error: err instanceof Error ? err.message : String(err) }) 215 + } 230 216 231 - return pdsRes.body 217 + return pdsRes.body as { uri?: string; cid?: string } 232 218 } 233 219 234 220 export async function pdsUploadBlob(
+3 -4
packages/hatk/src/server.ts
··· 666 666 } 667 667 668 668 // Dev-only: create a session cookie for any DID (for testing) 669 - if (url.pathname === '/__dev/login' && devMode) { 669 + if (url.pathname === '/__dev/login' && devMode && oauth) { 670 670 const did = url.searchParams.get('did') 671 671 if (!did) return withCors(jsonError(400, 'did required', acceptEncoding)) 672 672 const cookieValue = await createSessionCookie(did) ··· 934 934 error = err.message 935 935 return withCors(jsonError(500, err.message, acceptEncoding)) 936 936 } finally { 937 - if (isXrpc || isAdmin) { 937 + if ((isXrpc || isAdmin) && elapsed) { 938 938 emit('server', 'request', { 939 939 method: request.method, 940 940 path: url.pathname, 941 - status_code: 0, // Status not easily available here, but emit for timing 942 - duration_ms: elapsed!(), 941 + duration_ms: elapsed(), 943 942 collection: url.searchParams.get('collection') || undefined, 944 943 query: url.searchParams.get('q') || undefined, 945 944 error,
+1 -1
packages/hatk/src/test.ts
··· 99 99 ddlStatements.push(generateCreateTableSQL(schema, SQLITE_DIALECT)) 100 100 } 101 101 102 - // In-memory database 102 + // In-memory SQLite — faster startup, no native module issues in Vite's module runner 103 103 const { adapter, searchPort } = await createAdapter('sqlite') 104 104 setSearchPort(searchPort) 105 105 await initDatabase(adapter, ':memory:', schemas, ddlStatements)
+9 -18
packages/hatk/src/vite-plugin.ts
··· 2 2 import { resolve } from 'node:path' 3 3 import { existsSync } from 'node:fs' 4 4 import { execSync } from 'node:child_process' 5 + import { isHatkRoute } from './adapter.ts' 5 6 6 7 /** Boot the local PDS if a docker-compose.yml exists. */ 7 8 async function ensurePds(): Promise<void> { ··· 41 42 for (const envName of ['hatk', 'client']) { 42 43 const env = server.environments[envName] 43 44 if (!env?.moduleGraph) continue 45 + // TODO: uses internal Vite module graph API — may break across Vite minor versions 44 46 for (const mod of (env.moduleGraph as any).idToModuleMap?.values?.() ?? []) { 45 47 const url = mod.url || '' 46 48 if (/\.(css|scss|less|styl|stylus|pcss|postcss)(\?|$)/.test(url)) { ··· 145 147 // The runner has its own module instances with registered handlers; Node's instance is empty. 146 148 ;(globalThis as any).__hatk_callXrpc = mod.callXrpc 147 149 148 - // Capture cookie parser for SSR viewer resolution 150 + // Capture cookie parser and name for SSR viewer resolution 149 151 const ssrParseSessionCookie: ((request: Request) => Promise<{ did: string } | null>) | null = mod.parseSessionCookie ?? null 150 152 ;(globalThis as any).__hatk_parseSessionCookie = ssrParseSessionCookie 153 + ;(globalThis as any).__hatk_sessionCookieName = mod.getSessionCookieName?.() ?? '__hatk_session' 151 154 152 - const hasRenderer = ssrGetRenderer && ssrGetRenderer() 153 - if (hasRenderer) { 155 + if (ssrGetRenderer?.()) { 154 156 console.log('[hatk] SSR ready') 155 157 } 156 158 ··· 158 160 server.middlewares.use(async (req: any, res: any, next: any) => { 159 161 const url = new URL(req.url!, `http://localhost:${devPort}`) 160 162 161 - const isBackend = 162 - url.pathname.startsWith('/xrpc/') || 163 - url.pathname.startsWith('/oauth/') || 164 - url.pathname.startsWith('/.well-known/') || 165 - url.pathname.startsWith('/og/') || 166 - url.pathname.startsWith('/admin') || 167 - url.pathname.startsWith('/repos') || 168 - url.pathname.startsWith('/info/') || 169 - url.pathname === '/_health' || 170 - url.pathname === '/robots.txt' || 171 - url.pathname === '/auth/logout' || 172 - url.pathname.startsWith('/__dev/') 173 - 174 - if (!isBackend || !handler) { 163 + if (!isHatkRoute(url.pathname) || !handler) { 175 164 next() 176 165 return 177 166 } ··· 194 183 // SSR middleware — returned function runs after htmlFallback but before indexHtmlMiddleware 195 184 return () => { 196 185 server.middlewares.use(async (req: any, res: any, next: any) => { 197 - if (!hasRenderer) { 186 + if (!ssrGetRenderer?.()) { 198 187 next() 199 188 return 200 189 } ··· 217 206 const request = new Request(fullUrl.href, { headers }) 218 207 219 208 // Resolve viewer from session cookie for SSR 209 + // TODO: globalThis.__hatk_viewer is not safe for concurrent SSR requests. 210 + // Replace with AsyncLocalStorage when callXrpc supports per-request context. 220 211 let viewer: { did: string } | null = null 221 212 if (ssrParseSessionCookie) { 222 213 try {