···11+22+Default to using Bun instead of Node.js.
33+44+- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
55+- Use `bun test` instead of `jest` or `vitest`
66+- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
77+- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
88+- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
99+- Use `bunx <package> <command>` instead of `npx <package> <command>`
1010+- Bun automatically loads .env, so don't use dotenv.
1111+1212+## APIs
1313+1414+- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
1515+- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
1616+- `Bun.redis` for Redis. Don't use `ioredis`.
1717+- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
1818+- `WebSocket` is built-in. Don't use `ws`.
1919+- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
2020+- Bun.$`ls` instead of execa.
2121+2222+## Testing
2323+2424+Use `bun test` to run tests.
2525+2626+```ts#index.test.ts
2727+import { test, expect } from "bun:test";
2828+2929+test("hello world", () => {
3030+ expect(1).toBe(1);
3131+});
3232+```
3333+3434+## Frontend
3535+3636+Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
3737+3838+Server:
3939+4040+```ts#index.ts
4141+import index from "./index.html"
4242+4343+Bun.serve({
4444+ routes: {
4545+ "/": index,
4646+ "/api/users/:id": {
4747+ GET: (req) => {
4848+ return new Response(JSON.stringify({ id: req.params.id }));
4949+ },
5050+ },
5151+ },
5252+ // optional websocket support
5353+ websocket: {
5454+ open: (ws) => {
5555+ ws.send("Hello, world!");
5656+ },
5757+ message: (ws, message) => {
5858+ ws.send(message);
5959+ },
6060+ close: (ws) => {
6161+ // handle close
6262+ }
6363+ },
6464+ development: {
6565+ hmr: true,
6666+ console: true,
6767+ }
6868+})
6969+```
7070+7171+HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
7272+7373+```html#index.html
7474+<html>
7575+ <body>
7676+ <h1>Hello, world!</h1>
7777+ <script type="module" src="./frontend.tsx"></script>
7878+ </body>
7979+</html>
8080+```
8181+8282+With the following `frontend.tsx`:
8383+8484+```tsx#frontend.tsx
8585+import React from "react";
8686+import { createRoot } from "react-dom/client";
8787+8888+// import .css files directly and it works
8989+import './index.css';
9090+9191+const root = createRoot(document.body);
9292+9393+export default function Frontend() {
9494+ return <h1>Hello, world!</h1>;
9595+}
9696+9797+root.render(<Frontend />);
9898+```
9999+100100+Then, run index.ts
101101+102102+```sh
103103+bun --hot ./index.ts
104104+```
105105+106106+For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+15
apps/webhook-service/README.md
···11+# webhook-service
22+33+To install dependencies:
44+55+```bash
66+bun install
77+```
88+99+To run:
1010+1111+```bash
1212+bun run index.ts
1313+```
1414+1515+This project was created using `bun init` in bun v1.3.10. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
···11+import { SQL } from 'bun';
22+import { createLogger } from '@wispplace/observability';
33+import type { Main as WhRecord } from '@wispplace/lexicons/types/place/wisp/v2/wh';
44+55+/** A webhook entry as returned from the DB, with ownership info split out from the KV key. */
66+export interface WebhookEntry {
77+ ownerDid: string;
88+ rkey: string;
99+ record: WhRecord;
1010+}
1111+1212+const logger = createLogger('webhook-service:db');
1313+1414+export const db = new SQL(
1515+ process.env.DATABASE_URL ||
1616+ (process.env.NODE_ENV === 'production'
1717+ ? (() => { throw new Error('DATABASE_URL is required in production'); })()
1818+ : 'postgres://postgres:postgres@localhost:5432/wisp')
1919+);
2020+2121+// Create tables on startup
2222+await db`
2323+ CREATE TABLE IF NOT EXISTS webhooks (
2424+ did TEXT NOT NULL,
2525+ rkey TEXT NOT NULL,
2626+ url TEXT NOT NULL,
2727+ scope_aturi TEXT NOT NULL,
2828+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
2929+ created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
3030+ updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
3131+ PRIMARY KEY (did, rkey)
3232+ )
3333+`;
3434+3535+await db`
3636+ CREATE TABLE IF NOT EXISTS webhook_records (
3737+ k TEXT PRIMARY KEY,
3838+ v JSONB NOT NULL,
3939+ updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())
4040+ )
4141+`;
4242+4343+/**
4444+ * Find all webhook records whose scope AT-URI targets the given DID.
4545+ * Matches exact DID scope (`at://did`) and collection/rkey sub-scopes (`at://did/...`).
4646+ * Used as the primary lookup when a firehose event arrives from a DID.
4747+ */
4848+export async function findWebhooksForDid(scopeDid: string): Promise<WebhookEntry[]> {
4949+ const exact = `at://${scopeDid}`;
5050+ const prefix = `at://${scopeDid}/`;
5151+ const rows = await db<Array<{ k: string; v: WhRecord }>>`
5252+ SELECT k, v FROM webhook_records
5353+ WHERE v->'scope'->>'aturi' = ${exact}
5454+ OR starts_with(v->'scope'->>'aturi', ${prefix})
5555+ `;
5656+ return rows.map(row => {
5757+ const slash = row.k.indexOf('/');
5858+ return {
5959+ ownerDid: row.k.slice(0, slash),
6060+ rkey: row.k.slice(slash + 1),
6161+ record: row.v,
6262+ };
6363+ });
6464+}
6565+6666+/**
6767+ * Find all webhook records that have backlinks enabled.
6868+ * These are checked against every firehose event to see if the record body
6969+ * references the webhook's scoped DID/collection.
7070+ */
7171+export async function findBacklinkWebhooks(): Promise<WebhookEntry[]> {
7272+ const rows = await db<Array<{ k: string; v: WhRecord }>>`
7373+ SELECT k, v FROM webhook_records
7474+ WHERE (v->'scope'->>'backlinks')::boolean = true
7575+ `;
7676+ return rows.map(row => {
7777+ const slash = row.k.indexOf('/');
7878+ return {
7979+ ownerDid: row.k.slice(0, slash),
8080+ rkey: row.k.slice(slash + 1),
8181+ record: row.v,
8282+ };
8383+ });
8484+}
8585+8686+/** Load all webhook records. Used for diagnostics/admin views. */
8787+export async function loadAllWebhooks(): Promise<Array<{ did: string; rkey: string; record: WhRecord }>> {
8888+ const rows = await db<Array<{ k: string; v: WhRecord }>>`
8989+ SELECT k, v FROM webhook_records
9090+ `;
9191+ return rows.map(row => {
9292+ const [did, rkey] = row.k.split('/') as [string, string];
9393+ return { did, rkey, record: row.v };
9494+ });
9595+}
9696+9797+/**
9898+ * Insert or update a webhook record in both tables.
9999+ * `webhooks` holds structured columns for quick filtering; `webhook_records` holds the full JSONB record.
100100+ * Key is `did/rkey`.
101101+ */
102102+export async function upsertWebhookRecord(did: string, rkey: string, record: WhRecord): Promise<void> {
103103+ const k = `${did}/${rkey}`;
104104+ try {
105105+ await db`
106106+ INSERT INTO webhooks (did, rkey, url, scope_aturi, enabled, created_at, updated_at)
107107+ VALUES (${did}, ${rkey}, ${record.url}, ${record.scope.aturi}, ${record.enabled ?? true},
108108+ EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
109109+ ON CONFLICT (did, rkey) DO UPDATE SET
110110+ url = EXCLUDED.url,
111111+ scope_aturi = EXCLUDED.scope_aturi,
112112+ enabled = EXCLUDED.enabled,
113113+ updated_at = EXTRACT(EPOCH FROM NOW())
114114+ `;
115115+ await db`
116116+ INSERT INTO webhook_records (k, v, updated_at)
117117+ VALUES (${k}, ${record}, EXTRACT(EPOCH FROM NOW()))
118118+ ON CONFLICT (k) DO UPDATE SET
119119+ v = EXCLUDED.v,
120120+ updated_at = EXTRACT(EPOCH FROM NOW())
121121+ `;
122122+ } catch (err) {
123123+ logger.error(`[DB] upsertWebhookRecord error for ${k}`, err);
124124+ throw err;
125125+ }
126126+}
127127+128128+/** Remove a webhook record from both tables. Called when a place.wisp.v2.wh delete event arrives. */
129129+export async function deleteWebhookRecord(did: string, rkey: string): Promise<void> {
130130+ const k = `${did}/${rkey}`;
131131+ try {
132132+ await db`DELETE FROM webhooks WHERE did = ${did} AND rkey = ${rkey}`;
133133+ await db`DELETE FROM webhook_records WHERE k = ${k}`;
134134+ } catch (err) {
135135+ logger.error(`[DB] deleteWebhookRecord error for ${k}`, err);
136136+ throw err;
137137+ }
138138+}
139139+140140+/** Close all database connections gracefully. */
141141+export async function closeDatabase(): Promise<void> {
142142+ await db.close();
143143+ logger.info('[DB] Database connections closed');
144144+}
+94
apps/webhook-service/src/lib/delivery.ts
···11+import { createHmac, randomUUID } from 'node:crypto';
22+import type { WebhookEntry } from './db';
33+import type { EventKind } from './matcher';
44+import { config } from '../config';
55+import { createLogger } from '@wispplace/observability';
66+77+const logger = createLogger('webhook-service:delivery');
88+99+export interface WebhookPayload {
1010+ id: string;
1111+ event: EventKind;
1212+ did: string;
1313+ collection: string;
1414+ rkey: string;
1515+ cid?: string;
1616+ record?: unknown;
1717+ timestamp: string;
1818+}
1919+2020+/**
2121+ * Signs a payload body with the webhook's shared secret using HMAC-SHA256.
2222+ * Returns a `sha256=<hex>` string for the `X-Webhook-Signature` header.
2323+ * Note: the secret is stored in the user's PDS record, so this provides
2424+ * transport integrity rather than authentication of the sender.
2525+ */
2626+function sign(secret: string, body: string): string {
2727+ return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
2828+}
2929+3030+async function attempt(url: string, body: string, signature?: string): Promise<void> {
3131+ const headers: Record<string, string> = {
3232+ 'Content-Type': 'application/json',
3333+ 'User-Agent': 'wisp.place-webhook/1.0',
3434+ };
3535+ if (signature) headers['X-Webhook-Signature'] = signature;
3636+3737+ const res = await fetch(url, {
3838+ method: 'POST',
3939+ headers,
4040+ body,
4141+ signal: AbortSignal.timeout(config.deliveryTimeoutMs),
4242+ });
4343+4444+ if (!res.ok) {
4545+ throw new Error(`HTTP ${res.status}`);
4646+ }
4747+}
4848+4949+/**
5050+ * Delivers a firehose event to a webhook URL with exponential backoff retries.
5151+ * The payload includes the event kind, AT-URI components, CID, full record, and a unique ID.
5252+ * If the webhook record has a `secret`, the payload is signed and the signature is sent
5353+ * in the `X-Webhook-Signature` header.
5454+ */
5555+export async function deliverWebhook(
5656+ entry: WebhookEntry,
5757+ eventDid: string,
5858+ eventCollection: string,
5959+ eventRkey: string,
6060+ eventKind: EventKind,
6161+ eventCid?: string,
6262+ eventRecord?: unknown,
6363+): Promise<void> {
6464+ const { record, ownerDid, rkey } = entry;
6565+ const payload: WebhookPayload = {
6666+ id: randomUUID(),
6767+ event: eventKind,
6868+ did: eventDid,
6969+ collection: eventCollection,
7070+ rkey: eventRkey,
7171+ cid: eventCid,
7272+ record: eventRecord,
7373+ timestamp: new Date().toISOString(),
7474+ };
7575+7676+ const body = JSON.stringify(payload);
7777+ const signature = record.secret ? sign(record.secret, body) : undefined;
7878+7979+ for (let attempt_n = 1; attempt_n <= config.deliveryMaxRetries; attempt_n++) {
8080+ try {
8181+ await attempt(record.url, body, signature);
8282+ logger.info(`[delivery] ok ${ownerDid}/${rkey} → ${record.url}`);
8383+ return;
8484+ } catch (err) {
8585+ const isLast = attempt_n === config.deliveryMaxRetries;
8686+ if (isLast) {
8787+ logger.warn(`Failed to deliver webhook ${ownerDid}/${rkey} → ${record.url} after ${attempt_n} attempts`, { err });
8888+ } else {
8989+ const delay = 1000 * 2 ** (attempt_n - 1);
9090+ await new Promise(r => setTimeout(r, delay));
9191+ }
9292+ }
9393+ }
9494+}
+154
apps/webhook-service/src/lib/firehose.ts
···11+import { IdResolver } from '@atproto/identity';
22+import { BunFirehose, isBun, type CommitEvt, type Event } from '@wispplace/bun-firehose';
33+import { Firehose } from '@atproto/sync';
44+import type { Main as WhRecord } from '@wispplace/lexicons/types/place/wisp/v2/wh';
55+import { createLogger } from '@wispplace/observability';
66+import { config } from '../config';
77+import { getCached, setCached, invalidate } from './registry';
88+import { upsertWebhookRecord, deleteWebhookRecord, findWebhooksForDid, findBacklinkWebhooks } from './db';
99+import { matchWebhooks } from './matcher';
1010+import { deliverWebhook } from './delivery';
1111+1212+const logger = createLogger('webhook-service:firehose');
1313+const idResolver = new IdResolver();
1414+1515+let lastEventTime = Date.now();
1616+let isConnected = false;
1717+1818+export function getFirehoseHealth() {
1919+ return {
2020+ connected: isConnected,
2121+ lastEventTime,
2222+ timeSinceLastEvent: Date.now() - lastEventTime,
2323+ healthy: isConnected && (Date.now() - lastEventTime < 60000),
2424+ };
2525+}
2626+2727+async function getWebhooksForEvent(eventDid: string) {
2828+ // Direct scope matches: cached by eventDid
2929+ let direct = getCached(eventDid);
3030+ if (!direct) {
3131+ direct = await findWebhooksForDid(eventDid);
3232+ setCached(eventDid, direct);
3333+ }
3434+3535+ // Backlink matches: cached under a fixed key
3636+ let backlink = getCached('__backlinks__');
3737+ if (!backlink) {
3838+ backlink = await findBacklinkWebhooks();
3939+ setCached('__backlinks__', backlink);
4040+ }
4141+4242+ // Combine, deduplicate by ownerDid/rkey
4343+ const seen = new Set(direct.map(e => `${e.ownerDid}/${e.rkey}`));
4444+ const combined = [...direct];
4545+ for (const entry of backlink) {
4646+ const k = `${entry.ownerDid}/${entry.rkey}`;
4747+ if (!seen.has(k)) {
4848+ seen.add(k);
4949+ combined.push(entry);
5050+ }
5151+ }
5252+ return combined;
5353+}
5454+5555+async function handleEvent(evt: Event | CommitEvt): Promise<void> {
5656+ try {
5757+ lastEventTime = Date.now();
5858+5959+ if (!('event' in evt)) return;
6060+ if (evt.event !== 'create' && evt.event !== 'update' && evt.event !== 'delete') return;
6161+ const { did, collection, rkey, record, cid, event } = evt as CommitEvt;
6262+6363+ // Keep DB up to date and invalidate cache when webhook records change
6464+ if (collection === 'place.wisp.v2.wh') {
6565+ logger.info(`[wh] Received ${event} for ${did}/${rkey}`);
6666+ if (event === 'delete') {
6767+ deleteWebhookRecord(did, rkey).catch(err =>
6868+ logger.error(`[DB] Failed to delete webhook ${did}/${rkey}`, err)
6969+ );
7070+ } else if (record) {
7171+ logger.debug(`[wh] raw record: ${JSON.stringify(record)}`);
7272+ const wh = record as WhRecord;
7373+ if (!wh.scope?.aturi || !wh.url) {
7474+ logger.error(`[wh] Skipping ${did}/${rkey} — record failed validation`, { record });
7575+ } else {
7676+ logger.info(`[wh] scope=${wh.scope.aturi} url=${wh.url} enabled=${wh.enabled ?? true}`);
7777+ upsertWebhookRecord(did, rkey, wh).catch(err =>
7878+ logger.error(`[DB] Failed to upsert webhook ${did}/${rkey}`, err)
7979+ );
8080+ }
8181+ } else {
8282+ logger.warn(`[wh] ${event} ${did}/${rkey} — record missing from commit`);
8383+ }
8484+ invalidate(did);
8585+ invalidate('__backlinks__');
8686+ return;
8787+ }
8888+8989+ // Lookup webhooks for this event (cache-first)
9090+ const candidates = await getWebhooksForEvent(did);
9191+ if (candidates.length === 0) return;
9292+9393+ const matched = matchWebhooks(candidates, did, collection, rkey, event, record);
9494+ if (matched.length === 0) return;
9595+9696+ logger.info(`[deliver] ${event} ${did}/${collection}/${rkey} → ${matched.length} webhook(s)`);
9797+9898+ await Promise.allSettled(
9999+ matched.map(entry =>
100100+ deliverWebhook(entry, did, collection, rkey, event, cid?.toString(), record)
101101+ )
102102+ );
103103+ } catch (err) {
104104+ logger.error('Unexpected error in handleEvent', err);
105105+ }
106106+}
107107+108108+function handleError(err: Error): void {
109109+ logger.error('Firehose error', err);
110110+}
111111+112112+let firehoseHandle: { destroy: () => void } | null = null;
113113+114114+export function startFirehose(): void {
115115+ logger.info(`Starting firehose (runtime: ${isBun ? 'Bun' : 'Node.js'})`);
116116+ isConnected = true;
117117+118118+ if (isBun) {
119119+ const f = new BunFirehose({
120120+ idResolver,
121121+ service: config.firehoseService,
122122+ unauthenticatedCommits: true,
123123+ handleEvent,
124124+ onError: handleError,
125125+ });
126126+ f.start();
127127+ firehoseHandle = { destroy: () => f.destroy() };
128128+ } else {
129129+ const f = new Firehose({
130130+ idResolver,
131131+ service: config.firehoseService,
132132+ handleEvent: handleEvent as any,
133133+ onError: handleError,
134134+ });
135135+ f.start();
136136+ firehoseHandle = { destroy: () => f.destroy() };
137137+ }
138138+139139+ setInterval(() => {
140140+ const health = getFirehoseHealth();
141141+ if (health.timeSinceLastEvent > 30000) {
142142+ logger.warn(`No firehose events for ${Math.round(health.timeSinceLastEvent / 1000)}s`);
143143+ } else {
144144+ logger.info(`Firehose alive, last event ${Math.round(health.timeSinceLastEvent / 1000)}s ago`);
145145+ }
146146+ }, 30000);
147147+}
148148+149149+export function stopFirehose(): void {
150150+ logger.info('Stopping firehose');
151151+ isConnected = false;
152152+ firehoseHandle?.destroy();
153153+ firehoseHandle = null;
154154+}
+117
apps/webhook-service/src/lib/matcher.ts
···11+import type { WebhookEntry } from './db';
22+33+export type EventKind = 'create' | 'update' | 'delete';
44+55+interface ParsedAtUri {
66+ did: string;
77+ collection?: string;
88+ rkey?: string;
99+}
1010+1111+function parseAtUri(aturi: string): ParsedAtUri | null {
1212+ const withoutScheme = aturi.replace(/^at:\/\//, '');
1313+ const parts = withoutScheme.split('/');
1414+ const did = parts[0];
1515+ if (!did) return null;
1616+ return {
1717+ did,
1818+ collection: parts[1] || undefined,
1919+ rkey: parts[2] || undefined,
2020+ };
2121+}
2222+2323+/** Matches a collection segment against a glob pattern */
2424+function matchesGlob(pattern: string, value: string): boolean {
2525+ if (!pattern.includes('*')) return pattern === value;
2626+ const escaped = pattern.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'));
2727+ return new RegExp(`^${escaped.join('.*')}$`).test(value);
2828+}
2929+3030+/**
3131+ * Checks whether a serialised record body contains a reference to the given DID/collection.
3232+ * When collection contains a glob, scans for any `at://did/<collection>` URI that matches.
3333+ */
3434+function containsReference(record: unknown, did: string, collection?: string): boolean {
3535+ const json = JSON.stringify(record);
3636+3737+ if (!collection) {
3838+ return json.includes(`at://${did}`) || json.includes(`"${did}"`);
3939+ }
4040+4141+ if (!collection.includes('*')) {
4242+ return json.includes(`at://${did}/${collection}`);
4343+ }
4444+4545+ // Glob collection: scan for all at://did/... URIs and match the collection segment
4646+ const prefix = `at://${did}/`;
4747+ let idx = json.indexOf(prefix);
4848+ while (idx !== -1) {
4949+ const rest = json.slice(idx + prefix.length);
5050+ const end = rest.search(/[/"\\]/);
5151+ const col = end === -1 ? rest : rest.slice(0, end);
5252+ if (col && matchesGlob(collection, col)) return true;
5353+ idx = json.indexOf(prefix, idx + prefix.length);
5454+ }
5555+ return false;
5656+}
5757+5858+/**
5959+ * Filters a set of webhook candidates against a firehose event.
6060+ *
6161+ * A webhook matches if:
6262+ * - It is enabled
6363+ * - The event kind is in its `events` filter (or no filter is set)
6464+ * - **Direct match**: the event DID/collection/rkey falls within the webhook's scope AT-URI
6565+ * (collection supports glob patterns, e.g. `app.bsky.*`)
6666+ * - **Backlink match**: `scope.backlinks` is true and the serialised record body contains
6767+ * a reference to the scope DID/collection
6868+ */
6969+export function matchWebhooks(
7070+ webhooks: WebhookEntry[],
7171+ eventDid: string,
7272+ eventCollection: string,
7373+ eventRkey: string,
7474+ eventKind: EventKind,
7575+ eventRecord: unknown,
7676+): WebhookEntry[] {
7777+ const matched: WebhookEntry[] = [];
7878+7979+ for (const entry of webhooks) {
8080+ const { record } = entry;
8181+8282+ if (record.enabled === false) continue;
8383+8484+ if (record.events && record.events.length > 0) {
8585+ if (!record.events.includes(eventKind)) continue;
8686+ }
8787+8888+ const scope = parseAtUri(record.scope.aturi);
8989+ if (!scope) continue;
9090+9191+ const backlinks = record.scope.backlinks === true;
9292+9393+ let directMatch = false;
9494+ if (scope.did === eventDid) {
9595+ if (!scope.collection) {
9696+ directMatch = true;
9797+ } else if (matchesGlob(scope.collection, eventCollection)) {
9898+ if (!scope.rkey || scope.rkey === eventRkey) {
9999+ directMatch = true;
100100+ }
101101+ }
102102+ }
103103+104104+ if (directMatch) {
105105+ matched.push(entry);
106106+ continue;
107107+ }
108108+109109+ if (backlinks && eventDid !== scope.did && eventRecord != null) {
110110+ if (containsReference(eventRecord, scope.did, scope.collection)) {
111111+ matched.push(entry);
112112+ }
113113+ }
114114+ }
115115+116116+ return matched;
117117+}
+24
apps/webhook-service/src/lib/registry.ts
···11+import { LRUCache } from 'lru-cache';
22+import type { WebhookEntry } from './db';
33+44+/**
55+ * LRU cache of DB query results, keyed by scope DID or `'__backlinks__'`.
66+ * Avoids hitting the DB on every firehose event for DIDs with no webhooks.
77+ * Invalidated when a place.wisp.v2.wh record changes for a given DID.
88+ */
99+const cache = new LRUCache<string, WebhookEntry[]>({
1010+ max: parseInt(process.env.WEBHOOK_CACHE_MAX || '1000', 10),
1111+ ttl: parseInt(process.env.WEBHOOK_CACHE_TTL_MS || '60000', 10),
1212+});
1313+1414+export function getCached(scopeDid: string): WebhookEntry[] | undefined {
1515+ return cache.get(scopeDid);
1616+}
1717+1818+export function setCached(scopeDid: string, entries: WebhookEntry[]): void {
1919+ cache.set(scopeDid, entries);
2020+}
2121+2222+export function invalidate(scopeDid: string): void {
2323+ cache.delete(scopeDid);
2424+}
···44 "defs": {
55 "main": {
66 "type": "record",
77- "description": "Webhook configuration for AT Protocol record events. Fires an HTTP POST to a URL before or after a matching record event is processed.",
77+ "description": "Webhook configuration for AT Protocol record events. Fires an HTTP POST to a URL when a matching record event is observed on the firehose.",
88 "key": "any",
99 "record": {
1010 "type": "object",
1111 "required": [
1212 "scope",
1313 "url",
1414- "phase",
1515- "events",
1614 "createdAt"
1715 ],
1816 "properties": {
1917 "scope": {
2020- "type": "union",
2121- "refs": [
2222- "#atUri",
2323- "#nsid"
2424- ],
2525- "description": "What to watch. An AT-URI scopes to a specific DID, collection, or record. An NSID watches that collection globally across all DIDs."
1818+ "type": "ref",
1919+ "ref": "#atUri",
2020+ "description": "What to watch. An AT-URI scopes to a specific DID, collection, or record."
2621 },
2722 "url": {
2823 "type": "string",
···3025 "maxLength": 2048,
3126 "description": "HTTPS endpoint to POST the webhook payload to."
3227 },
3333- "phase": {
3434- "type": "string",
3535- "enum": [
3636- "pre",
3737- "post"
3838- ],
3939- "description": "Whether the webhook should fire before ('pre') or after ('post') the record event is processed."
4040- },
4128 "events": {
4229 "type": "array",
4330 "items": {
···4835 "delete"
4936 ]
5037 },
5151- "description": "Which record events to trigger on. 'create' fires when a new record is created. 'update' fires when an existing record is updated. 'delete' fires when a record is deleted.",
3838+ "description": "Which record events to trigger on. Defaults to all events if omitted.",
5239 "maxLength": 3
5340 },
5441 "secret": {
5542 "type": "string",
4343+ "maxLength": 256,
5644 "description": "Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request."
5745 },
5846 "enabled": {
5947 "type": "boolean",
6060- "description": "Whether the webhook is active. Default to true if omitted."
4848+ "description": "Whether the webhook is active. Defaults to true if omitted."
6149 },
6250 "createdAt": {
6351 "type": "string",
···7765 "aturi": {
7866 "type": "string",
7967 "format": "at-uri"
8080- }
8181- }
8282- },
8383- "nsid": {
8484- "type": "object",
8585- "description": "Watch all records of this collection type globally across any DID.",
8686- "required": [
8787- "nsid"
8888- ],
8989- "properties": {
9090- "nsid": {
9191- "type": "string",
9292- "format": "nsid"
6868+ },
6969+ "backlinks": {
7070+ "type": "boolean",
7171+ "description": "If true, also watch for records in any repo that reference this DID and collection."
9372 }
9473 }
9574 }
···77 /*#__PURE__*/ v.literal("place.wisp.v2.wh#atUri"),
88 ),
99 aturi: /*#__PURE__*/ v.resourceUriString(),
1010+ /**
1111+ * If true, also watch for records in any repo that reference this DID and collection.
1212+ */
1313+ backlinks: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()),
1014});
1115const _mainSchema = /*#__PURE__*/ v.record(
1216 /*#__PURE__*/ v.string(),
···1721 */
1822 createdAt: /*#__PURE__*/ v.datetimeString(),
1923 /**
2020- * Whether the webhook is active. Default to true if omitted.
2424+ * Whether the webhook is active. Defaults to true if omitted.
2125 */
2226 enabled: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()),
2327 /**
2424- * Which record events to trigger on. 'create' fires when a new record is created. 'update' fires when an existing record is updated. 'delete' fires when a record is deleted.
2828+ * Which record events to trigger on. Defaults to all events if omitted.
2529 * @maxLength 3
2630 */
2727- events: /*#__PURE__*/ v.constrain(
2828- /*#__PURE__*/ v.array(
2929- /*#__PURE__*/ v.literalEnum(["create", "delete", "update"]),
3131+ events: /*#__PURE__*/ v.optional(
3232+ /*#__PURE__*/ v.constrain(
3333+ /*#__PURE__*/ v.array(
3434+ /*#__PURE__*/ v.literalEnum(["create", "delete", "update"]),
3535+ ),
3636+ [/*#__PURE__*/ v.arrayLength(0, 3)],
3037 ),
3131- [/*#__PURE__*/ v.arrayLength(0, 3)],
3238 ),
3339 /**
3434- * Whether the webhook should fire before ('pre') or after ('post') the record event is processed.
3535- */
3636- phase: /*#__PURE__*/ v.literalEnum(["post", "pre"]),
3737- /**
3838- * What to watch. An AT-URI scopes to a specific DID, collection, or record. An NSID watches that collection globally across all DIDs.
4040+ * What to watch. An AT-URI scopes to a specific DID, collection, or record.
3941 */
4042 get scope() {
4141- return /*#__PURE__*/ v.variant([atUriSchema, nsidSchema]);
4343+ return atUriSchema;
4244 },
4345 /**
4446 * Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request.
4747+ * @maxLength 256
4548 */
4646- secret: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()),
4949+ secret: /*#__PURE__*/ v.optional(
5050+ /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
5151+ /*#__PURE__*/ v.stringLength(0, 256),
5252+ ]),
5353+ ),
4754 /**
4855 * HTTPS endpoint to POST the webhook payload to.
4956 * @maxLength 2048
···5360 ]),
5461 }),
5562);
5656-const _nsidSchema = /*#__PURE__*/ v.object({
5757- $type: /*#__PURE__*/ v.optional(
5858- /*#__PURE__*/ v.literal("place.wisp.v2.wh#nsid"),
5959- ),
6060- nsid: /*#__PURE__*/ v.nsidString(),
6161-});
62636364type atUri$schematype = typeof _atUriSchema;
6465type main$schematype = typeof _mainSchema;
6565-type nsid$schematype = typeof _nsidSchema;
66666767export interface atUriSchema extends atUri$schematype {}
6868export interface mainSchema extends main$schematype {}
6969-export interface nsidSchema extends nsid$schematype {}
70697170export const atUriSchema = _atUriSchema as atUriSchema;
7271export const mainSchema = _mainSchema as mainSchema;
7373-export const nsidSchema = _nsidSchema as nsidSchema;
74727573export interface AtUri extends v.InferInput<typeof atUriSchema> {}
7674export interface Main extends v.InferInput<typeof mainSchema> {}
7777-export interface Nsid extends v.InferInput<typeof nsidSchema> {}
78757976declare module "@atcute/lexicons/ambient" {
8077 interface Records {
+12-24
packages/@wispplace/lexicons/src/lexicons.ts
···10351035 main: {
10361036 type: 'record',
10371037 description:
10381038- 'Webhook configuration for AT Protocol record events. Fires an HTTP POST to a URL before or after a matching record event is processed.',
10381038+ 'Webhook configuration for AT Protocol record events. Fires an HTTP POST to a URL when a matching record event is observed on the firehose.',
10391039 key: 'any',
10401040 record: {
10411041 type: 'object',
10421042- required: ['scope', 'url', 'phase', 'events', 'createdAt'],
10421042+ required: ['scope', 'url', 'createdAt'],
10431043 properties: {
10441044 scope: {
10451045- type: 'union',
10461046- refs: ['lex:place.wisp.v2.wh#atUri', 'lex:place.wisp.v2.wh#nsid'],
10451045+ type: 'ref',
10461046+ ref: 'lex:place.wisp.v2.wh#atUri',
10471047 description:
10481048- 'What to watch. An AT-URI scopes to a specific DID, collection, or record. An NSID watches that collection globally across all DIDs.',
10481048+ 'What to watch. An AT-URI scopes to a specific DID, collection, or record.',
10491049 },
10501050 url: {
10511051 type: 'string',
···10531053 maxLength: 2048,
10541054 description: 'HTTPS endpoint to POST the webhook payload to.',
10551055 },
10561056- phase: {
10571057- type: 'string',
10581058- enum: ['pre', 'post'],
10591059- description:
10601060- "Whether the webhook should fire before ('pre') or after ('post') the record event is processed.",
10611061- },
10621056 events: {
10631057 type: 'array',
10641058 items: {
···10661060 enum: ['create', 'update', 'delete'],
10671061 },
10681062 description:
10691069- "Which record events to trigger on. 'create' fires when a new record is created. 'update' fires when an existing record is updated. 'delete' fires when a record is deleted.",
10631063+ 'Which record events to trigger on. Defaults to all events if omitted.',
10701064 maxLength: 3,
10711065 },
10721066 secret: {
10731067 type: 'string',
10681068+ maxLength: 256,
10741069 description:
10751070 "Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request.",
10761071 },
10771072 enabled: {
10781073 type: 'boolean',
10791074 description:
10801080- 'Whether the webhook is active. Default to true if omitted.',
10751075+ 'Whether the webhook is active. Defaults to true if omitted.',
10811076 },
10821077 createdAt: {
10831078 type: 'string',
···10971092 type: 'string',
10981093 format: 'at-uri',
10991094 },
11001100- },
11011101- },
11021102- nsid: {
11031103- type: 'object',
11041104- description:
11051105- 'Watch all records of this collection type globally across any DID.',
11061106- required: ['nsid'],
11071107- properties: {
11081108- nsid: {
11091109- type: 'string',
11101110- format: 'nsid',
10951095+ backlinks: {
10961096+ type: 'boolean',
10971097+ description:
10981098+ 'If true, also watch for records in any repo that reference this DID and collection.',
11111099 },
11121100 },
11131101 },
···16161717export interface Main {
1818 $type: 'place.wisp.v2.wh'
1919- scope: $Typed<AtUri> | $Typed<Nsid> | { $type: string }
1919+ scope: AtUri
2020 /** HTTPS endpoint to POST the webhook payload to. */
2121 url: string
2222- /** Whether the webhook should fire before ('pre') or after ('post') the record event is processed. */
2323- phase: 'pre' | 'post'
2424- /** Which record events to trigger on. 'create' fires when a new record is created. 'update' fires when an existing record is updated. 'delete' fires when a record is deleted. */
2525- events: ('create' | 'update' | 'delete')[]
2222+ /** Which record events to trigger on. Defaults to all events if omitted. */
2323+ events?: ('create' | 'update' | 'delete')[]
2624 /** Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request. */
2725 secret?: string
2828- /** Whether the webhook is active. Default to true if omitted. */
2626+ /** Whether the webhook is active. Defaults to true if omitted. */
2927 enabled?: boolean
3028 /** Timestamp of when the webhook was created. */
3129 createdAt: string
···5250export interface AtUri {
5351 $type?: 'place.wisp.v2.wh#atUri'
5452 aturi: string
5353+ /** If true, also watch for records in any repo that reference this DID and collection. */
5454+ backlinks?: boolean
5555}
56565757const hashAtUri = 'atUri'
···6363export function validateAtUri<V>(v: V) {
6464 return validate<AtUri & V>(v, id, hashAtUri)
6565}
6666-6767-/** Watch all records of this collection type globally across any DID. */
6868-export interface Nsid {
6969- $type?: 'place.wisp.v2.wh#nsid'
7070- nsid: string
7171-}
7272-7373-const hashNsid = 'nsid'
7474-7575-export function isNsid<V>(v: V) {
7676- return is$typed(v, id, hashNsid)
7777-}
7878-7979-export function validateNsid<V>(v: V) {
8080- return validate<Nsid & V>(v, id, hashNsid)
8181-}