Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
1
fork

Configure Feed

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

webhooks uses jetstream

+532 -122
+2 -2
apps/main-app/public/components/ui/button.tsx
··· 12 12 destructive: 13 13 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', 14 14 outline: 15 - 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 15 + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 dark:hover:text-primary', 16 16 secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 17 - ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 17 + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 dark:hover:text-primary', 18 18 link: 'text-primary underline-offset-4 hover:underline', 19 19 }, 20 20 size: {
+1
apps/webhook-service/package.json
··· 11 11 "@atproto/identity": "^0.4.10", 12 12 "@atproto/sync": "^0.1.40", 13 13 "@atproto/syntax": "^0.5.0", 14 + "@wispplace/atproto-utils": "workspace:*", 14 15 "@wispplace/bun-firehose": "workspace:*", 15 16 "@wispplace/lexicons": "workspace:*", 16 17 "@wispplace/observability": "workspace:*",
+1 -1
apps/webhook-service/src/config.ts
··· 1 1 export const config = { 2 - firehoseService: process.env.FIREHOSE_SERVICE || 'wss://relay.fire.hose.cam', 2 + jetstreamUrl: process.env.JETSTREAM_URL || 'wss://jetstream2.us-east.bsky.network/subscribe', 3 3 healthPort: parseInt(process.env.HEALTH_PORT || '3003', 10), 4 4 deliveryTimeoutMs: parseInt(process.env.DELIVERY_TIMEOUT_MS || '10000', 10), 5 5 deliveryMaxRetries: parseInt(process.env.DELIVERY_MAX_RETRIES || '3', 10),
+29 -3
apps/webhook-service/src/index.ts
··· 1 1 import { createLogger } from '@wispplace/observability' 2 2 import { config } from './config' 3 - import { closeDatabase, db } from './lib/db' 4 - import { getFirehoseHealth, startFirehose, stopFirehose } from './lib/firehose' 3 + import { closeDatabase, db, loadAllWebhooks } from './lib/db' 4 + import { runStartupBackfill } from './lib/backfill' 5 + import { getFirehoseHealth, initScopeDids, startFirehose, stopFirehose } from './lib/firehose' 5 6 import { closeRedisPublisher } from './lib/redis' 6 7 7 8 const logger = createLogger('webhook-service') ··· 104 105 105 106 async function main() { 106 107 logger.info('Starting webhook-service') 107 - logger.info(`Firehose: ${config.firehoseService}`) 108 + logger.info(`Jetstream: ${config.jetstreamUrl}`) 108 109 logger.info(`Health endpoint: http://localhost:${config.healthPort}/health`) 109 110 111 + const webhooks = await loadAllWebhooks() 112 + if (webhooks.length === 0) { 113 + logger.info('[registry] No webhook records in DB') 114 + } else { 115 + logger.info(`[registry] Tracking ${webhooks.length} webhook(s) across ${new Set(webhooks.map((w) => w.record.scope.aturi.replace(/^at:\/\//, '').split('/')[0])).size} DID(s)`) 116 + for (const w of webhooks) { 117 + logger.info( 118 + `[registry] ${w.did}/${w.rkey}` + 119 + ` scope=${w.record.scope.aturi}` + 120 + ` events=${w.record.events?.join(',') ?? 'all'}` + 121 + ` backlinks=${w.record.scope.backlinks ?? false}` + 122 + ` enabled=${w.record.enabled ?? true}` + 123 + ` url=${w.record.url}`, 124 + ) 125 + } 126 + } 127 + 128 + // Populate sync pre-filter sets before starting the firehose 129 + initScopeDids(webhooks) 130 + 110 131 startFirehose() 132 + 133 + // Backfill any place.wisp.v2.wh records that existed before this run 134 + await runStartupBackfill() 135 + // Re-init after backfill in case it added new webhooks 136 + initScopeDids(await loadAllWebhooks()) 111 137 } 112 138 113 139 main().catch((err) => {
+92
apps/webhook-service/src/lib/backfill.ts
··· 1 + import { getPdsForDid } from '@wispplace/atproto-utils' 2 + import type { Main as WhRecord } from '@wispplace/lexicons/types/place/wisp/v2/wh' 3 + import { createLogger } from '@wispplace/observability' 4 + import { listAllKnownDids, upsertWebhookRecord } from './db' 5 + 6 + const logger = createLogger('webhook-service:backfill') 7 + 8 + interface ListRecordsResponse { 9 + records: Array<{ 10 + uri: string 11 + cid: string 12 + value: WhRecord 13 + }> 14 + cursor?: string 15 + } 16 + 17 + /** 18 + * Fetch all place.wisp.v2.wh records for a DID from their PDS. 19 + * Pages through all results using the cursor. 20 + */ 21 + async function fetchWhRecordsForDid(did: string): Promise<Array<{ rkey: string; record: WhRecord }>> { 22 + const pdsUrl = await getPdsForDid(did) 23 + if (!pdsUrl) return [] 24 + 25 + const results: Array<{ rkey: string; record: WhRecord }> = [] 26 + let cursor: string | undefined 27 + 28 + do { 29 + const params = new URLSearchParams({ 30 + repo: did, 31 + collection: 'place.wisp.v2.wh', 32 + limit: '100', 33 + }) 34 + if (cursor) params.set('cursor', cursor) 35 + 36 + const res = await fetch(`${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`, { 37 + signal: AbortSignal.timeout(10_000), 38 + }) 39 + 40 + if (!res.ok) { 41 + if (res.status === 404) return results // DID has no records of this type 42 + logger.warn(`[backfill] PDS returned ${res.status} for ${did}`) 43 + return results 44 + } 45 + 46 + const data = (await res.json()) as ListRecordsResponse 47 + cursor = data.cursor 48 + 49 + for (const r of data.records) { 50 + const rkey = r.uri.split('/').at(-1) 51 + if (!rkey) continue 52 + if (!r.value.scope?.aturi || !r.value.url) continue 53 + results.push({ rkey, record: r.value }) 54 + } 55 + } while (cursor) 56 + 57 + return results 58 + } 59 + 60 + /** 61 + * On startup, scan all known DIDs for existing place.wisp.v2.wh records 62 + * and populate the local DB. This recovers webhook registrations that were 63 + * created while the service was offline. 64 + */ 65 + export async function runStartupBackfill(): Promise<void> { 66 + const dids = await listAllKnownDids() 67 + if (dids.length === 0) { 68 + logger.info('[backfill] No known DIDs to scan') 69 + return 70 + } 71 + 72 + logger.info(`[backfill] Scanning ${dids.length} known DIDs for place.wisp.v2.wh records`) 73 + 74 + let found = 0 75 + let failed = 0 76 + 77 + for (const did of dids) { 78 + try { 79 + const records = await fetchWhRecordsForDid(did) 80 + for (const { rkey, record } of records) { 81 + await upsertWebhookRecord(did, rkey, record) 82 + found++ 83 + logger.info(`[backfill] Imported ${did}/${rkey}`) 84 + } 85 + } catch (err) { 86 + failed++ 87 + logger.warn(`[backfill] Failed to scan ${did}`, { err: String(err) }) 88 + } 89 + } 90 + 91 + logger.info(`[backfill] Done — ${found} webhook record(s) imported, ${failed} DID(s) failed`) 92 + }
+50
apps/webhook-service/src/lib/db.ts
··· 43 43 ` 44 44 45 45 await db` 46 + CREATE TABLE IF NOT EXISTS firehose_cursor ( 47 + id TEXT PRIMARY KEY DEFAULT 'singleton', 48 + seq BIGINT NOT NULL, 49 + saved_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 50 + ) 51 + ` 52 + 53 + await db` 46 54 CREATE TABLE IF NOT EXISTS webhook_event_logs ( 47 55 id BIGSERIAL PRIMARY KEY, 48 56 owner_did TEXT NOT NULL, ··· 233 241 status: r.status as 'ok' | 'failed', 234 242 deliveredAt: r.delivered_at, 235 243 })) 244 + } 245 + 246 + /** Persist the current firehose sequence number so restarts can resume. */ 247 + export async function saveCursor(seq: number): Promise<void> { 248 + await db` 249 + INSERT INTO firehose_cursor (id, seq, saved_at) 250 + VALUES ('singleton', ${seq}, NOW()) 251 + ON CONFLICT (id) DO UPDATE SET seq = EXCLUDED.seq, saved_at = NOW() 252 + ` 253 + } 254 + 255 + /** Load the last saved firehose sequence number, or undefined if none. */ 256 + export async function loadCursor(): Promise<number | undefined> { 257 + const rows = await db<Array<{ seq: number }>>` 258 + SELECT seq FROM firehose_cursor WHERE id = 'singleton' 259 + ` 260 + return rows[0]?.seq 261 + } 262 + 263 + /** 264 + * Collect all DIDs we know about from the local DB. 265 + * Used during startup backfill to find repos that may already have 266 + * place.wisp.v2.wh records that arrived before the service started. 267 + */ 268 + export async function listAllKnownDids(): Promise<string[]> { 269 + const dids = new Set<string>() 270 + 271 + const sites = await db<Array<{ did: string }>>` 272 + SELECT DISTINCT did FROM sites WHERE did IS NOT NULL AND did <> '' 273 + ` 274 + for (const r of sites) dids.add(r.did) 275 + 276 + try { 277 + const sessions = await db<Array<{ sub: string }>>` 278 + SELECT DISTINCT sub FROM oauth_sessions WHERE sub IS NOT NULL AND sub <> '' 279 + ` 280 + for (const r of sessions) dids.add(r.sub) 281 + } catch { 282 + // oauth_sessions schema may differ; skip gracefully 283 + } 284 + 285 + return [...dids].sort() 236 286 } 237 287 238 288 /** Close all database connections gracefully. */
+187 -88
apps/webhook-service/src/lib/firehose.ts
··· 1 - import { IdResolver } from '@atproto/identity' 2 - import { Firehose } from '@atproto/sync' 3 - import { BunFirehose, type CommitEvt, type Event, isBun } from '@wispplace/bun-firehose' 4 1 import type { Main as WhRecord } from '@wispplace/lexicons/types/place/wisp/v2/wh' 5 2 import { createLogger } from '@wispplace/observability' 6 3 import { config } from '../config' 7 - import { deleteWebhookRecord, findBacklinkWebhooks, findWebhooksForDid, upsertWebhookRecord } from './db' 4 + import { 5 + deleteWebhookRecord, 6 + findBacklinkWebhooks, 7 + findWebhooksForDid, 8 + loadAllWebhooks, 9 + upsertWebhookRecord, 10 + type WebhookEntry, 11 + } from './db' 8 12 import { deliverWebhook } from './delivery' 13 + import { JetstreamClient, type JetstreamEvent } from './jetstream' 9 14 import { matchWebhooks } from './matcher' 10 15 import { getCached, invalidate, setCached } from './registry' 11 16 12 17 const logger = createLogger('webhook-service:firehose') 13 - const idResolver = new IdResolver() 14 18 15 19 let lastEventTime = Date.now() 16 20 let isConnected = false 21 + let totalEvents = 0 22 + let totalMatched = 0 17 23 18 24 export function getFirehoseHealth() { 19 25 return { 20 26 connected: isConnected, 21 27 lastEventTime, 22 28 timeSinceLastEvent: Date.now() - lastEventTime, 23 - healthy: isConnected && Date.now() - lastEventTime < 60000, 29 + healthy: isConnected && Date.now() - lastEventTime < 60_000, 30 + } 31 + } 32 + 33 + export function getEventStats() { 34 + return { events: totalEvents, matched: totalMatched } 35 + } 36 + 37 + let directScopeDids = new Set<string>() 38 + let backlinkScopeDids = new Set<string>() 39 + 40 + export function initScopeDids(webhooks: Array<{ record: { scope: { aturi: string; backlinks?: boolean } } }>): void { 41 + directScopeDids = new Set() 42 + backlinkScopeDids = new Set() 43 + for (const w of webhooks) { 44 + const did = w.record.scope.aturi.replace(/^at:\/\//, '').split('/')[0] 45 + if (!did) continue 46 + directScopeDids.add(did) 47 + if (w.record.scope.backlinks) backlinkScopeDids.add(did) 48 + } 49 + logger.info(`[registry] tracking ${directScopeDids.size} scope DID(s), ${backlinkScopeDids.size} with backlinks`) 50 + restartDirectJetstream() 51 + if (backlinkScopeDids.size > 0 && !backlinkJetstream) { 52 + startBacklinkJetstream() 53 + } else if (backlinkScopeDids.size === 0 && backlinkJetstream) { 54 + stopBacklinkJetstream() 24 55 } 25 56 } 26 57 27 - async function getWebhooksForEvent(eventDid: string) { 28 - // Direct scope matches: cached by eventDid 58 + function extractAtUriDids(obj: unknown, found: Set<string>): void { 59 + if (typeof obj === 'string') { 60 + if (obj.startsWith('at://')) { 61 + const rest = obj.slice(5) 62 + const slash = rest.indexOf('/') 63 + const did = slash === -1 ? rest : rest.slice(0, slash) 64 + if (did) found.add(did) 65 + } 66 + return 67 + } 68 + if (Array.isArray(obj)) { 69 + for (const v of obj) extractAtUriDids(v, found) 70 + return 71 + } 72 + if (obj !== null && typeof obj === 'object') { 73 + for (const v of Object.values(obj)) extractAtUriDids(v, found) 74 + } 75 + } 76 + 77 + function recordReferencesAnyOf(record: unknown, dids: Set<string>): boolean { 78 + if (record == null || dids.size === 0) return false 79 + const found = new Set<string>() 80 + extractAtUriDids(record, found) 81 + for (const did of found) { 82 + if (dids.has(did)) return true 83 + } 84 + return false 85 + } 86 + 87 + async function getWebhooksForEvent(eventDid: string, eventRecord: unknown) { 29 88 let direct = getCached(eventDid) 30 89 if (!direct) { 31 90 direct = await findWebhooksForDid(eventDid) 32 91 setCached(eventDid, direct) 33 92 } 34 93 35 - // Backlink matches: cached under a fixed key 36 94 let backlink = getCached('__backlinks__') 37 95 if (!backlink) { 38 96 backlink = await findBacklinkWebhooks() 39 97 setCached('__backlinks__', backlink) 40 98 } 41 99 42 - // Combine, deduplicate by ownerDid/rkey 100 + const includeBacklinks = backlink.length > 0 && recordReferencesAnyOf(eventRecord, backlinkScopeDids) 101 + if (!includeBacklinks) return direct 102 + 43 103 const seen = new Set(direct.map((e) => `${e.ownerDid}/${e.rkey}`)) 44 104 const combined = [...direct] 45 105 for (const entry of backlink) { ··· 52 112 return combined 53 113 } 54 114 55 - async function handleEvent(evt: Event | CommitEvt): Promise<void> { 115 + async function deliver(did: string, collection: string, rkey: string, op: string, cid: string | undefined, record: unknown): Promise<void> { 116 + const candidates = await getWebhooksForEvent(did, record) 117 + if (candidates.length === 0) return 118 + 119 + const matched = matchWebhooks(candidates, did, collection, rkey, op as any, record) 120 + 121 + if (process.env.FILTER_DEBUG) { 122 + for (const c of candidates) { 123 + logger.debug( 124 + matched.includes(c) 125 + ? `[filter] ✓ ${c.ownerDid}/${c.rkey} scope=${c.record.scope.aturi}` 126 + : `[filter] ✗ ${c.ownerDid}/${c.rkey} scope=${c.record.scope.aturi}`, 127 + ) 128 + } 129 + } 130 + 131 + if (matched.length === 0) return 132 + totalMatched += matched.length 133 + logger.info(`[deliver] ${op} ${did}/${collection}/${rkey} → ${matched.length} webhook(s)`) 134 + await Promise.allSettled(matched.map((entry) => deliverWebhook(entry, did, collection, rkey, op as any, cid, record))) 135 + } 136 + 137 + async function handleWhRecord(op: string, did: string, rkey: string, record: unknown): Promise<void> { 138 + logger.info(`[wh] ${op} ${did}/${rkey}`) 139 + if (op === 'delete') { 140 + deleteWebhookRecord(did, rkey).catch((err) => logger.error(`[DB] delete ${did}/${rkey}`, err)) 141 + } else if (record) { 142 + const wh = record as WhRecord 143 + if (!wh.scope?.aturi || !wh.url) { 144 + logger.error(`[wh] Skipping ${did}/${rkey} — invalid record`, { record }) 145 + } else { 146 + logger.info(`[wh] scope=${wh.scope.aturi} url=${wh.url} enabled=${wh.enabled ?? true}`) 147 + upsertWebhookRecord(did, rkey, wh).catch((err) => logger.error(`[DB] upsert ${did}/${rkey}`, err)) 148 + } 149 + } else { 150 + logger.warn(`[wh] ${op} ${did}/${rkey} — record missing`) 151 + } 152 + invalidate(did) 153 + invalidate('__backlinks__') 154 + loadAllWebhooks().then(initScopeDids).catch(() => {}) 155 + } 156 + 157 + let directJetstream: JetstreamClient | null = null 158 + 159 + async function handleDirectEvent(event: JetstreamEvent): Promise<void> { 56 160 try { 161 + if (event.kind !== 'commit' || !event.commit) return 57 162 lastEventTime = Date.now() 58 - 59 - if (!('event' in evt)) return 60 - if (evt.event !== 'create' && evt.event !== 'update' && evt.event !== 'delete') return 61 - const { did, collection, rkey, record, cid, event } = evt as CommitEvt 163 + const { did } = event 164 + const { operation: op, collection, rkey, record, cid } = event.commit 165 + if (op !== 'create' && op !== 'update' && op !== 'delete') return 166 + totalEvents++ 62 167 63 - // Keep DB up to date and invalidate cache when webhook records change 64 168 if (collection === 'place.wisp.v2.wh') { 65 - logger.info(`[wh] Received ${event} for ${did}/${rkey}`) 66 - if (event === 'delete') { 67 - deleteWebhookRecord(did, rkey).catch((err) => logger.error(`[DB] Failed to delete webhook ${did}/${rkey}`, err)) 68 - } else if (record) { 69 - logger.debug(`[wh] raw record: ${JSON.stringify(record)}`) 70 - const wh = record as WhRecord 71 - if (!wh.scope?.aturi || !wh.url) { 72 - logger.error(`[wh] Skipping ${did}/${rkey} — record failed validation`, { record }) 73 - } else { 74 - logger.info(`[wh] scope=${wh.scope.aturi} url=${wh.url} enabled=${wh.enabled ?? true}`) 75 - upsertWebhookRecord(did, rkey, wh).catch((err) => 76 - logger.error(`[DB] Failed to upsert webhook ${did}/${rkey}`, err), 77 - ) 78 - } 79 - } else { 80 - logger.warn(`[wh] ${event} ${did}/${rkey} — record missing from commit`) 81 - } 82 - invalidate(did) 83 - invalidate('__backlinks__') 169 + await handleWhRecord(op, did, rkey, record) 84 170 return 85 171 } 86 172 87 - // Lookup webhooks for this event (cache-first) 88 - const candidates = await getWebhooksForEvent(did) 89 - if (candidates.length === 0) return 173 + await deliver(did, collection, rkey, op, cid, record) 174 + } catch (err) { 175 + logger.error('Direct Jetstream event error', err) 176 + } 177 + } 178 + 179 + function restartDirectJetstream(): void { 180 + const cursor = directJetstream?.cursor 181 + directJetstream?.destroy() 182 + 183 + if (directScopeDids.size === 0) { 184 + directJetstream = null 185 + return 186 + } 187 + 188 + directJetstream = new JetstreamClient({ 189 + url: config.jetstreamUrl, 190 + wantedDids: [...directScopeDids], 191 + cursor, 192 + onEvent: handleDirectEvent, 193 + onError: (err) => logger.error('Direct Jetstream error', err), 194 + onConnect: () => { isConnected = true; logger.info('Direct Jetstream connected') }, 195 + onDisconnect: () => { isConnected = false }, 196 + }) 197 + directJetstream.start() 198 + } 90 199 91 - const matched = matchWebhooks(candidates, did, collection, rkey, event, record) 92 - if (matched.length === 0) return 200 + let backlinkJetstream: JetstreamClient | null = null 93 201 94 - logger.info(`[deliver] ${event} ${did}/${collection}/${rkey} → ${matched.length} webhook(s)`) 202 + async function handleBacklinkEvent(event: JetstreamEvent): Promise<void> { 203 + try { 204 + if (event.kind !== 'commit' || !event.commit) return 205 + lastEventTime = Date.now() 206 + const { did } = event 207 + const { operation: op, collection, rkey, record, cid } = event.commit 208 + if (op !== 'create' && op !== 'update' && op !== 'delete') return 95 209 96 - await Promise.allSettled( 97 - matched.map((entry) => deliverWebhook(entry, did, collection, rkey, event, cid?.toString(), record)), 98 - ) 210 + if (collection === 'place.wisp.v2.wh' && !directScopeDids.has(did)) { 211 + await handleWhRecord(op, did, rkey, record) 212 + return 213 + } 214 + 215 + if (!recordReferencesAnyOf(record, backlinkScopeDids)) return 216 + 217 + await deliver(did, collection, rkey, op, cid, record) 99 218 } catch (err) { 100 - logger.error('Unexpected error in handleEvent', err) 219 + logger.error('Backlink Jetstream event error', err) 101 220 } 102 221 } 103 222 104 - function handleError(err: Error): void { 105 - logger.error('Firehose error', err) 223 + function startBacklinkJetstream(): void { 224 + backlinkJetstream = new JetstreamClient({ 225 + url: config.jetstreamUrl, 226 + onEvent: handleBacklinkEvent, 227 + onError: (err) => logger.error('Backlink Jetstream error', err), 228 + onConnect: () => logger.info('Backlink Jetstream connected'), 229 + onDisconnect: () => logger.warn('Backlink Jetstream disconnected, reconnecting'), 230 + }) 231 + backlinkJetstream.start() 106 232 } 107 233 108 - let firehoseHandle: { destroy: () => void } | null = null 234 + function stopBacklinkJetstream(): void { 235 + backlinkJetstream?.destroy() 236 + backlinkJetstream = null 237 + } 109 238 110 239 export function startFirehose(): void { 111 - logger.info(`Starting firehose (runtime: ${isBun ? 'Bun' : 'Node.js'})`) 112 - 113 - if (isBun) { 114 - const f = new BunFirehose({ 115 - idResolver, 116 - service: config.firehoseService, 117 - unauthenticatedCommits: true, 118 - handleEvent, 119 - onError: handleError, 120 - onConnect: () => { 121 - isConnected = true 122 - logger.info('Firehose connected') 123 - }, 124 - onDisconnect: () => { 125 - isConnected = false 126 - logger.warn('Firehose disconnected, will reconnect') 127 - }, 128 - }) 129 - f.start() 130 - firehoseHandle = { destroy: () => f.destroy() } 131 - } else { 132 - isConnected = true 133 - const f = new Firehose({ 134 - idResolver, 135 - service: config.firehoseService, 136 - handleEvent: handleEvent as any, 137 - onError: handleError, 138 - }) 139 - f.start() 140 - firehoseHandle = { destroy: () => f.destroy() } 141 - } 240 + logger.info(`Jetstream: ${config.jetstreamUrl}`) 241 + restartDirectJetstream() 242 + if (backlinkScopeDids.size > 0) startBacklinkJetstream() 142 243 143 244 setInterval(() => { 144 - const health = getFirehoseHealth() 145 - if (health.timeSinceLastEvent > 30000) { 146 - logger.warn(`No firehose events for ${Math.round(health.timeSinceLastEvent / 1000)}s`) 147 - } else { 148 - logger.info(`Firehose alive, last event ${Math.round(health.timeSinceLastEvent / 1000)}s ago`) 245 + if (Date.now() - lastEventTime > 30_000) { 246 + logger.warn(`No events for ${Math.round((Date.now() - lastEventTime) / 1000)}s`) 149 247 } 150 - }, 30000) 248 + }, 30_000) 151 249 } 152 250 153 251 export function stopFirehose(): void { 154 - logger.info('Stopping firehose') 252 + logger.info('Stopping Jetstream consumers') 155 253 isConnected = false 156 - firehoseHandle?.destroy() 157 - firehoseHandle = null 254 + directJetstream?.destroy() 255 + directJetstream = null 256 + stopBacklinkJetstream() 158 257 }
+109
apps/webhook-service/src/lib/jetstream.ts
··· 1 + import { createLogger } from '@wispplace/observability' 2 + 3 + const logger = createLogger('webhook-service:jetstream') 4 + 5 + export interface JetstreamCommit { 6 + rev: string 7 + operation: 'create' | 'update' | 'delete' 8 + collection: string 9 + rkey: string 10 + record?: unknown 11 + cid?: string 12 + } 13 + 14 + export interface JetstreamEvent { 15 + did: string 16 + time_us: number 17 + kind: 'commit' | 'identity' | 'account' 18 + commit?: JetstreamCommit 19 + } 20 + 21 + export interface JetstreamOptions { 22 + url: string 23 + wantedDids?: string[] 24 + wantedCollections?: string[] 25 + cursor?: number 26 + onEvent: (event: JetstreamEvent) => void | Promise<void> 27 + onConnect?: () => void 28 + onDisconnect?: () => void 29 + onError?: (err: Error) => void 30 + 31 + } 32 + 33 + export class JetstreamClient { 34 + private ws: WebSocket | null = null 35 + private destroyed = false 36 + private reconnectTimer: ReturnType<typeof setTimeout> | null = null 37 + private lastCursor: number | undefined 38 + 39 + constructor(private opts: JetstreamOptions) { 40 + this.lastCursor = opts.cursor 41 + } 42 + 43 + start(): void { 44 + this.connect() 45 + } 46 + 47 + private buildUrl(cursor?: number): string { 48 + const url = new URL(this.opts.url) 49 + 50 + for (const did of this.opts.wantedDids ?? []) { 51 + url.searchParams.append('wantedDids', did) 52 + } 53 + for (const col of this.opts.wantedCollections ?? []) { 54 + url.searchParams.append('wantedCollections', col) 55 + } 56 + if (cursor !== undefined) { 57 + url.searchParams.set('cursor', String(cursor)) 58 + } 59 + 60 + return url.toString() 61 + } 62 + 63 + private connect(): void { 64 + if (this.destroyed) return 65 + 66 + const url = this.buildUrl(this.lastCursor) 67 + logger.info(`Connecting to Jetstream: ${url}`) 68 + 69 + const ws = new WebSocket(url) 70 + this.ws = ws 71 + 72 + ws.onopen = () => { 73 + logger.info('Jetstream connected') 74 + this.opts.onConnect?.() 75 + } 76 + 77 + ws.onmessage = async (e) => { 78 + try { 79 + const event = JSON.parse(e.data as string) as JetstreamEvent 80 + this.lastCursor = event.time_us 81 + await this.opts.onEvent(event) 82 + } catch (err) { 83 + this.opts.onError?.(err instanceof Error ? err : new Error(String(err))) 84 + } 85 + } 86 + 87 + ws.onclose = () => { 88 + if (this.destroyed) return 89 + logger.warn('Jetstream disconnected, reconnecting in 3s') 90 + this.opts.onDisconnect?.() 91 + this.reconnectTimer = setTimeout(() => this.connect(), 3_000) 92 + } 93 + 94 + ws.onerror = () => { 95 + this.opts.onError?.(new Error('Jetstream WebSocket error')) 96 + } 97 + } 98 + 99 + get cursor(): number | undefined { 100 + return this.lastCursor 101 + } 102 + 103 + destroy(): void { 104 + this.destroyed = true 105 + if (this.reconnectTimer) clearTimeout(this.reconnectTimer) 106 + this.ws?.close() 107 + this.ws = null 108 + } 109 + }
+60 -28
apps/webhook-service/src/lib/matcher.ts
··· 8 8 rkey?: string 9 9 } 10 10 11 + /*Compiled regexes keyed by glob pattern*/ 12 + const globCache = new Map<string, RegExp>() 13 + 14 + /**Parsed AT-URIs keyed by the raw aturi string*/ 15 + const atUriCache = new Map<string, ParsedAtUri | null>() 16 + 11 17 function parseAtUri(aturi: string): ParsedAtUri | null { 18 + const cached = atUriCache.get(aturi) 19 + if (cached !== undefined) return cached 20 + 12 21 const withoutScheme = aturi.replace(/^at:\/\//, '') 13 22 const parts = withoutScheme.split('/') 14 23 const did = parts[0] 15 - if (!did) return null 16 - return { 17 - did, 18 - collection: parts[1] || undefined, 19 - rkey: parts[2] || undefined, 24 + const result = did ? { did, collection: parts[1] || undefined, rkey: parts[2] || undefined } : null 25 + atUriCache.set(aturi, result) 26 + return result 27 + } 28 + 29 + function compileGlob(pattern: string): RegExp { 30 + let re = globCache.get(pattern) 31 + if (!re) { 32 + const escaped = pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')) 33 + re = new RegExp(`^${escaped.join('.*')}$`) 34 + globCache.set(pattern, re) 20 35 } 36 + return re 21 37 } 22 38 23 - /** Matches a collection segment against a glob pattern */ 24 39 function matchesGlob(pattern: string, value: string): boolean { 25 40 if (!pattern.includes('*')) return pattern === value 26 - const escaped = pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')) 27 - return new RegExp(`^${escaped.join('.*')}$`).test(value) 41 + return compileGlob(pattern).test(value) 28 42 } 29 43 30 44 /** 31 - * Checks whether a serialised record body contains a reference to the given DID/collection. 32 - * When collection contains a glob, scans for any `at://did/<collection>` URI that matches. 45 + * Recursively walk a parsed record object checking whether any string value 46 + * starts with `prefix` and has a collection segment matching `collectionRe`. 33 47 */ 34 - function containsReference(record: unknown, did: string, collection?: string): boolean { 35 - const json = JSON.stringify(record) 36 - 37 - if (!collection) { 38 - return json.includes(`at://${did}`) || json.includes(`"${did}"`) 48 + function walkForReference(obj: unknown, prefix: string, collectionRe: RegExp | null, exact: string | null): boolean { 49 + if (typeof obj === 'string') { 50 + const idx = obj.indexOf(prefix) 51 + if (idx === -1) return false 52 + const rest = obj.slice(idx + prefix.length) 53 + if (collectionRe === null && exact === null) return true // at://did — any reference 54 + const end = rest.search(/[/"\\]/) 55 + const col = end === -1 ? rest : rest.slice(0, end) 56 + if (!col) return false 57 + return exact !== null ? col === exact : collectionRe!.test(col) 39 58 } 40 - 41 - if (!collection.includes('*')) { 42 - return json.includes(`at://${did}/${collection}`) 59 + if (Array.isArray(obj)) { 60 + for (const v of obj) { 61 + if (walkForReference(v, prefix, collectionRe, exact)) return true 62 + } 63 + return false 43 64 } 65 + if (obj !== null && typeof obj === 'object') { 66 + for (const v of Object.values(obj)) { 67 + if (walkForReference(v, prefix, collectionRe, exact)) return true 68 + } 69 + } 70 + return false 71 + } 44 72 45 - // Glob collection: scan for all at://did/... URIs and match the collection segment 73 + /** 74 + * Checks whether a record contains a reference to the given DID/collection. 75 + * Uses a recursive walk and pre-compiled regex — no JSON.stringify. 76 + */ 77 + function containsReference(record: unknown, did: string, collection?: string): boolean { 46 78 const prefix = `at://${did}/` 47 - let idx = json.indexOf(prefix) 48 - while (idx !== -1) { 49 - const rest = json.slice(idx + prefix.length) 50 - const end = rest.search(/[/"\\]/) 51 - const col = end === -1 ? rest : rest.slice(0, end) 52 - if (col && matchesGlob(collection, col)) return true 53 - idx = json.indexOf(prefix, idx + prefix.length) 79 + 80 + if (!collection) { 81 + // Any reference to this DID at all 82 + return walkForReference(record, `at://${did}`, null, null) 54 83 } 55 - return false 84 + 85 + const collectionRe = collection.includes('*') ? compileGlob(collection) : null 86 + const exact = collectionRe ? null : collection 87 + return walkForReference(record, prefix, collectionRe, exact) 56 88 } 57 89 58 90 /** ··· 63 95 * - The event kind is in its `events` filter (or no filter is set) 64 96 * - **Direct match**: the event DID/collection/rkey falls within the webhook's scope AT-URI 65 97 * (collection supports glob patterns, e.g. `app.bsky.*`) 66 - * - **Backlink match**: `scope.backlinks` is true and the serialised record body contains 98 + * - **Backlink match**: `scope.backlinks` is true and the record body contains 67 99 * a reference to the scope DID/collection 68 100 */ 69 101 export function matchWebhooks(
+1
bun.lock
··· 145 145 "@atproto/identity": "^0.4.10", 146 146 "@atproto/sync": "^0.1.40", 147 147 "@atproto/syntax": "^0.5.0", 148 + "@wispplace/atproto-utils": "workspace:*", 148 149 "@wispplace/bun-firehose": "workspace:*", 149 150 "@wispplace/lexicons": "workspace:*", 150 151 "@wispplace/observability": "workspace:*",