diff --git a/backend/package.json b/backend/package.json index 8d7dc85..3bdbaf4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,6 +30,7 @@ "@bytelyst/llm": "*", "@bytelyst/palace": "*", "@bytelyst/logger": "*", + "@bytelyst/webhook-dispatch": "*", "fastify": "5.7.4", "jose": "^6.0.8", "zod": "^3.24.2" diff --git a/backend/src/lib/webhook-subscriber.ts b/backend/src/lib/webhook-subscriber.ts new file mode 100644 index 0000000..4978f92 --- /dev/null +++ b/backend/src/lib/webhook-subscriber.ts @@ -0,0 +1,90 @@ +/** + * Webhook subscriber — bridges the domain event bus to @bytelyst/webhook-dispatch. + * + * On startup, call `initWebhookSubscriber(app)` to register listeners on + * all domain events. When an event fires, it dispatches to all enabled + * webhook targets for the product. + */ + +import { dispatchToTargets, type WebhookTarget, type DeliveryResult, type DispatchOptions } from '@bytelyst/webhook-dispatch'; +import { getEventBus, type NoteLettEventMap } from './event-bus.js'; +import { PRODUCT_ID } from './product-config.js'; + +// ── Webhook target storage (in-memory for now, swap for Cosmos later) ── + +const targets: WebhookTarget[] = []; + +export function registerWebhookTarget(target: WebhookTarget): void { + const existing = targets.findIndex(t => t.id === target.id); + if (existing >= 0) { + targets[existing] = target; + } else { + targets.push(target); + } +} + +export function removeWebhookTarget(id: string): boolean { + const idx = targets.findIndex(t => t.id === id); + if (idx < 0) return false; + targets.splice(idx, 1); + return true; +} + +export function listWebhookTargets(): WebhookTarget[] { + return [...targets]; +} + +// ── Dispatch options ───────────────────────────────────────── + +let deliveryLog: DeliveryResult[] = []; + +const dispatchOptions: DispatchOptions = { + maxRetries: 3, + timeoutMs: 5_000, + onDelivery: (result) => { + deliveryLog.push(result); + // Keep last 1000 deliveries + if (deliveryLog.length > 1_000) { + deliveryLog = deliveryLog.slice(-500); + } + }, +}; + +export function getRecentDeliveries(limit = 50): DeliveryResult[] { + return deliveryLog.slice(-limit); +} + +// ── Event → Webhook bridge ─────────────────────────────────── + +async function dispatchEvent( + event: K, + payload: NoteLettEventMap[K], +): Promise { + if (targets.length === 0) return; + await dispatchToTargets( + targets, + event, + payload as unknown as Record, + PRODUCT_ID, + dispatchOptions, + ).catch(() => {}); +} + +const unsubscribers: (() => void)[] = []; + +export function initWebhookSubscriber(): void { + const bus = getEventBus(); + + unsubscribers.push( + bus.on('note.created', (payload) => dispatchEvent('note.created', payload)), + bus.on('note.updated', (payload) => dispatchEvent('note.updated', payload)), + bus.on('note.deleted', (payload) => dispatchEvent('note.deleted', payload)), + bus.on('task.created', (payload) => dispatchEvent('task.created', payload)), + bus.on('workspace.created', (payload) => dispatchEvent('workspace.created', payload)), + ); +} + +export function stopWebhookSubscriber(): void { + for (const unsub of unsubscribers) unsub(); + unsubscribers.length = 0; +} diff --git a/backend/src/server.ts b/backend/src/server.ts index d36971f..bcfea1a 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -16,6 +16,7 @@ import { noteCollaboratorRoutes } from './modules/note-collaborators/routes.js'; import { palaceRoutes } from './modules/palace/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { initEncryption } from './lib/field-encrypt.js'; +import { initWebhookSubscriber, stopWebhookSubscriber } from './lib/webhook-subscriber.js'; import { initDatastore } from './lib/datastore.js'; import { config } from './lib/config.js'; import { getAllFlags } from './lib/feature-flags.js'; @@ -74,7 +75,8 @@ await registerApiPlugin(palaceRoutes); // ── Start scheduler loop (F25) ──────────────────────────────────── startSchedulerLoop(); -app.addHook('onClose', async () => { stopSchedulerLoop(); }); +initWebhookSubscriber(); +app.addHook('onClose', async () => { stopSchedulerLoop(); stopWebhookSubscriber(); }); // ── Public read-only share (no auth) ─────────────────────────────── app.get('/api/public/note-shares/:token', async (req, reply) => {