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

title: Hooks description: Run custom logic at key points in the server lifecycle.#

Hooks let you run custom logic at key points in the hatk lifecycle, like when a user logs in via OAuth. Define them with defineHook() in the hooks/ directory.

on-login#

The on-login hook runs after a successful OAuth login. The most common use is calling ensureRepo to backfill the user's data so it's available immediately:

// hooks/on-login.ts
import { defineHook } from '$hatk'

export default defineHook('on-login', async (ctx) => {
  await ctx.ensureRepo(ctx.did)
})

This is three lines, but it's important: without it, a new user's existing records won't appear until the firehose (the AT Protocol's real-time event stream) delivers them. ensureRepo fetches the user's repository from their PDS, indexes it, and waits for the backfill to complete before returning.

Populating records on first login#

Since the hook has full database and record access, you can check for records and create them if needed. For example, copying a user's Bluesky profile to a custom profile collection on first login:

// hooks/on-login.ts
import { defineHook, type BskyActorProfile, type MyAppProfile } from '$hatk'

export default defineHook('on-login', async (ctx) => {
  await ctx.ensureRepo(ctx.did)

  // Check if user already has an app profile
  const existing = await ctx.lookup<MyAppProfile>('my.app.profile', 'did', [ctx.did])
  if (existing.has(ctx.did)) return

  // Copy from Bluesky profile
  const bsky = await ctx.lookup<BskyActorProfile>('app.bsky.actor.profile', 'did', [ctx.did])
  const profile = bsky.get(ctx.did)
  if (!profile) return

  await ctx.createRecord('my.app.profile', {
    displayName: profile.value.displayName,
    description: profile.value.description,
    avatar: profile.value.avatar,
    createdAt: new Date().toISOString(),
  }, { rkey: 'self' })
})

Hook context#

The on-login handler receives a context object with database access, record helpers, and the login event data:

Field Type Description
did string The DID of the user who logged in
ensureRepo (did: string) => Promise<void> Backfills the user's repo from their PDS and waits for completion
db.query (sql, params?) => Promise<unknown[]> Run a read query
db.run (sql, params?) => Promise<void> Run a write query (INSERT, UPDATE, DELETE)
lookup (collection, field, values) => Promise<Map> Look up records by field values
count (collection, field, values) => Promise<Map> Count records by field values
getRecords (collection, uris) => Promise<Map> Fetch records by URI
labels (uris) => Promise<Map> Get labels for URIs
blobUrl (did, ref, preset?) => string Generate a blob URL
createRecord (collection, record, opts?) => Promise<{uri?, cid?}> Write a record to the user's PDS and index locally
putRecord (collection, rkey, record) => Promise<{uri?, cid?}> Create or update a record on the user's PDS
deleteRecord (collection, rkey) => Promise<void> Delete a record from the user's PDS and local index

Error handling#

If a hook throws, the error is logged but does not block the login flow. The user still completes authentication successfully. Hooks have a 30-second timeout — if the hook takes longer, it is cancelled and the login proceeds.