Typed XRPC Actions Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Make callXrpc handle procedures (POST/body) and blob uploads on client and server, with SvelteKit remote functions (command) for mutations.
Architecture: Extract PDS proxy into shared functions. Register write ops as XRPC handlers. Update codegen for procedure-aware callXrpc. Update getViewer() to resolve from cookies via getRequestEvent(). Template uses command() remote functions for mutations.
Tech Stack: TypeScript, SvelteKit remote functions, AT Protocol OAuth/DPoP
Task 1: Extract PDS proxy logic into pds-proxy.ts
Files:
- Create:
packages/hatk/src/pds-proxy.ts - Modify:
packages/hatk/src/server.ts
Step 1: Create pds-proxy.ts
Move proxyToPds (server.ts:991-1052) and proxyToPdsRaw (server.ts:1055-1114) into a new file packages/hatk/src/pds-proxy.ts. Then add four high-level functions that wrap these:
import type { OAuthConfig } from './config.ts'
import { getSession } from './oauth/db.ts'
import { getServerKey } from './oauth/db.ts'
import { createDpopProof } from './oauth/dpop.ts'
import { refreshPdsSession } from './oauth/server.ts'
import { validateRecord } from '@bigmoves/lexicon'
import { getLexiconArray } from './database/schema.ts'
import { insertRecord, deleteRecord as dbDeleteRecord } from './database/db.ts'
export class ProxyError extends Error {
constructor(public status: number, message: string) {
super(message)
}
}
Move proxyToPds and proxyToPdsRaw as-is (private functions).
Then add these exported functions:
pdsCreateRecord(oauthConfig, viewer, input: { collection, repo?, rkey?, record })— validates record, gets session, proxies to PDScom.atproto.repo.createRecord, indexes locallypdsDeleteRecord(oauthConfig, viewer, input: { collection, rkey })— gets session, proxies to PDScom.atproto.repo.deleteRecord, deletes locallypdsPutRecord(oauthConfig, viewer, input: { collection, rkey, record, repo? })— validates, gets session, proxies to PDScom.atproto.repo.putRecord, re-indexespdsUploadBlob(oauthConfig, viewer, body: Uint8Array, contentType: string)— gets session, proxies raw to PDScom.atproto.repo.uploadBlob
All throw ProxyError on auth/validation/PDS errors.
Reference the existing server.ts route handlers (lines 726-848) for exact logic — each function is a direct extraction of that code.
Step 2: Update server.ts HTTP routes to use shared functions
Import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsUploadBlob, ProxyError } from ./pds-proxy.ts.
Replace each HTTP route handler body (createRecord ~727-763, deleteRecord ~766-792, putRecord ~795-831, uploadBlob ~834-848) with:
if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding))
const body = JSON.parse(await request.text())
try {
const result = await pdsCreateRecord(oauth, viewer, body)
return withCors(json(result, 200, acceptEncoding))
} catch (err: any) {
if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding))
throw err
}
(Adjust per endpoint — uploadBlob reads request.arrayBuffer() and passes content-type.)
Delete the now-unused proxyToPds and proxyToPdsRaw from the bottom of server.ts. Remove any imports that are now only used by the deleted functions (check getServerKey, createDpopProof — they may still be used by admin proxy or elsewhere).
Step 3: Verify
Run: npm run build
Step 4: Commit
refactor: extract PDS proxy logic into shared pds-proxy.ts
Task 2: Register write operations as core XRPC handlers
Files:
- Modify:
packages/hatk/src/server.ts:67-149(registerCoreHandlers) - Modify:
packages/hatk/src/main.ts:117 - Modify:
packages/hatk/src/dev-entry.ts:61
Step 1: Add oauth parameter to registerCoreHandlers
Change signature at server.ts:67:
export function registerCoreHandlers(collections: string[], oauth: OAuthConfig | null): void {
Add import for OAuthConfig type if needed.
Step 2: Register write handlers at end of registerCoreHandlers
if (oauth) {
registerCoreXrpcHandler('dev.hatk.createRecord', async (_params, _cursor, _limit, viewer, input) => {
if (!viewer) throw new InvalidRequestError('Authentication required')
return pdsCreateRecord(oauth, viewer, input as any)
})
registerCoreXrpcHandler('dev.hatk.deleteRecord', async (_params, _cursor, _limit, viewer, input) => {
if (!viewer) throw new InvalidRequestError('Authentication required')
return pdsDeleteRecord(oauth, viewer, input as any)
})
registerCoreXrpcHandler('dev.hatk.putRecord', async (_params, _cursor, _limit, viewer, input) => {
if (!viewer) throw new InvalidRequestError('Authentication required')
return pdsPutRecord(oauth, viewer, input as any)
})
registerCoreXrpcHandler('dev.hatk.uploadBlob', async (_params, _cursor, _limit, viewer, input) => {
if (!viewer) throw new InvalidRequestError('Authentication required')
return pdsUploadBlob(oauth, viewer, input as any, 'application/octet-stream')
})
}
Step 3: Update call sites
main.ts:117:registerCoreHandlers(collections, config.oauth)dev-entry.ts:61:registerCoreHandlers(collections, <oauth variable>)— check what the oauth config variable is named in dev-entry.ts
Step 4: Verify
Run: npm run build
Step 5: Commit
feat: register write operations as core XRPC handlers
Task 3: Update codegen for procedure-aware callXrpc
Files:
- Modify:
packages/hatk/src/cli.ts:1718-1787
Step 1: Collect procedure and blob nsids
After the entries loop (~line 1425), add:
const procedureNsids: string[] = []
const blobInputNsids: string[] = []
for (const { nsid, defType } of entries) {
if (defType === 'procedure') {
const lex = lexicons.get(nsid)
const inputEncoding = lex?.defs?.main?.input?.encoding
if (inputEncoding === '*/*') {
blobInputNsids.push(nsid)
} else {
procedureNsids.push(nsid)
}
}
}
Step 2: Replace callXrpc generation block
Replace lines ~1748-1769 with new code that:
- Emits
const _procedures = new Set([...])andconst _blobInputs = new Set([...]) - Uses
CallArg<K>type instead ofExtractParams— resolvesinputfor procedures,paramsfor queries - Server-side bridge: procedures pass
(nsid, {}, arg), queries pass(nsid, arg) - Client-side: blob inputs POST raw with
blob.type, procedures POST JSON, queries GET with query params
Delete the old ExtractParams type emission.
Step 3: Update getViewer() to resolve from cookies
In the getViewer() generation block (~lines 1771-1785), update the server-side path to try getRequestEvent() cookies:
clientOut += `\nexport function getViewer(): { did: string } | null {\n`
clientOut += ` const ssrViewer = (globalThis as any).__hatk_viewer\n`
clientOut += ` if (typeof window === 'undefined') {\n`
clientOut += ` if (ssrViewer) return ssrViewer\n`
clientOut += ` // Try resolving from request cookies (SvelteKit remote functions, load, etc.)\n`
clientOut += ` try {\n`
clientOut += ` const { getRequestEvent } = require('$app/server')\n`
clientOut += ` const event = getRequestEvent()\n`
clientOut += ` const cookieValue = event.cookies.get('__hatk_session')\n`
clientOut += ` // parseSessionCookie is sync-incompatible, so we can't call it here.\n`
clientOut += ` // The viewer should be set via layout.server.ts instead.\n`
clientOut += ` } catch {}\n`
clientOut += ` return null\n`
clientOut += ` }\n`
Wait — parseSessionCookie is async. getViewer() is sync. We can't call it.
Revised approach: Keep getViewer() sync. The viewer is already set on globalThis.__hatk_viewer by the layout.server.ts load function. Remote functions run in the same request context, so the viewer set during layout load is available. No change needed to getViewer() — it already works.
Verify: in the statusphere template's +layout.server.ts, the load function sets (globalThis as any).__hatk_viewer = viewer. Since remote functions run server-side in the same request lifecycle, getViewer() will pick it up from globalThis.__hatk_viewer.
Actually — globalThis is shared across all requests on the server. Setting __hatk_viewer in one request's layout load would leak to another request. This is a race condition.
Better approach: Make getViewer() async on the server and use the __hatk_parseSessionCookie bridge with getRequestEvent():
clientOut += `\nexport async function getViewer(): Promise<{ did: string } | null> {\n`
clientOut += ` if (typeof window === 'undefined') {\n`
clientOut += ` try {\n`
clientOut += ` const parse = (globalThis as any).__hatk_parseSessionCookie\n`
clientOut += ` if (parse) {\n`
clientOut += ` const { getRequestEvent } = await import('$app/server')\n`
clientOut += ` const event = getRequestEvent()\n`
clientOut += ` const cookieValue = event.cookies.get('__hatk_session')\n`
clientOut += ` if (cookieValue) {\n`
clientOut += ` const request = new Request('http://localhost', {\n`
clientOut += ` headers: { cookie: \`__hatk_session=\${cookieValue}\` },\n`
clientOut += ` })\n`
clientOut += ` return parse(request)\n`
clientOut += ` }\n`
clientOut += ` }\n`
clientOut += ` } catch {}\n`
clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n`
clientOut += ` }\n`
clientOut += ` try {\n`
clientOut += ` const mod = (globalThis as any).__hatk_auth\n`
clientOut += ` if (mod?.viewerDid) {\n`
clientOut += ` const did = mod.viewerDid()\n`
clientOut += ` if (did) return { did }\n`
clientOut += ` }\n`
clientOut += ` } catch {}\n`
clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n`
clientOut += `}\n`
This changes getViewer() from sync to async. Update any existing usages. The +layout.server.ts in the template already has its own cookie parsing, so this mainly benefits remote functions.
Note: The import('$app/server') will fail in non-SvelteKit contexts. The try/catch handles that gracefully, falling back to __hatk_viewer.
Step 4: Verify
Run: npm run build
Step 5: Commit
feat: codegen emits procedure-aware callXrpc with async getViewer
Task 4: Enable remote functions in statusphere template
Files:
- Modify:
/Users/chadmiller/code/hatk-template-statusphere/svelte.config.js - Create:
/Users/chadmiller/code/hatk-template-statusphere/app/routes/status.remote.ts - Modify:
/Users/chadmiller/code/hatk-template-statusphere/app/routes/+page.svelte
Step 1: Enable experimental remote functions
In svelte.config.js, add:
export default {
compilerOptions: {
experimental: {
async: true,
},
},
kit: {
adapter: adapter(),
files: { src: 'app' },
alias: {
$hatk: './hatk.generated.ts',
'$hatk/client': './hatk.generated.client.ts',
},
experimental: {
remoteFunctions: true,
},
},
}
Step 2: Regenerate client file
Run in template: npx hatk generate
Verify hatk.generated.client.ts has _procedures set and updated callXrpc.
Step 3: Create remote functions file
Create app/routes/status.remote.ts:
import { command } from '$app/server'
import { callXrpc, getViewer } from '$hatk/client'
export const createStatus = command(async (emoji: string) => {
const viewer = await getViewer()
if (!viewer) throw new Error('Not authenticated')
return callXrpc('dev.hatk.createRecord', {
collection: 'xyz.statusphere.status' as const,
repo: viewer.did,
record: { status: emoji, createdAt: new Date().toISOString() },
})
})
export const deleteStatus = command(async (rkey: string) => {
const viewer = await getViewer()
if (!viewer) throw new Error('Not authenticated')
return callXrpc('dev.hatk.deleteRecord', {
collection: 'xyz.statusphere.status' as const,
rkey,
})
})
Step 4: Update +page.svelte to use remote functions
Import remote functions:
import { createStatus, deleteStatus } from './status.remote'
Remove the manual xrpc() helper function.
Remove the local createStatus() and deleteStatus() functions and replace with calls to the imported remote functions:
async function handleCreateStatus(emoji: string) {
if (isMutating) return
const did = data.viewer!.did
const profile = viewerProfile
const optimisticItem: StatusView = {
uri: `at://${did}/xyz.statusphere.status/optimistic-${Date.now()}`,
status: emoji,
createdAt: new Date().toISOString(),
author: { did, handle: profile?.handle || did, displayName: profile?.displayName },
}
items = [optimisticItem, ...items]
isMutating = true
try {
const res = await createStatus(emoji)
items = items.map(i => i.uri === optimisticItem.uri ? { ...optimisticItem, uri: res.uri! } : i)
} catch {
items = items.filter(i => i.uri !== optimisticItem.uri)
} finally {
isMutating = false
}
}
async function handleDeleteStatus(uri: string) {
if (isMutating) return
const removed = items.find(i => i.uri === uri)
items = items.filter(i => i.uri !== uri)
isMutating = true
try {
await deleteStatus(uri.split('/').pop()!)
} catch {
if (removed) items = [removed, ...items]
} finally {
isMutating = false
}
}
Update onclick handlers in template to use handleCreateStatus and handleDeleteStatus.
Keep loadMore using client-side callXrpc directly (it's a query, not a mutation).
Step 5: Rebuild hatk and re-link
cd /path/to/hatk && npm run build
cd /path/to/statusphere && npm link @hatk/hatk
Step 6: Manual test
Start dev server and test:
- Sign in via OAuth
- Create a status (should use remote function → server-side callXrpc)
- Delete a status
- Load more (client-side callXrpc query)
Step 7: Commit
feat: use SvelteKit remote functions for statusphere mutations