first commit
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# `webhook-service.ts` — `WebhookService` implementation
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps `@suaveplan/webhook-client`'s `deliver` function with the plugin's orchestration layer: endpoint lookup, tenant scoping, per-delivery dedup, dead-letter sink wiring, error-code translation, and OpenTelemetry emission. The result is the single object registered in the host `ServiceRegistry` under the `webhookService` slot — downstream plugins call it to send signed events, fan out to every subscriber of an event, manage subscriptions programmatically, and fire the synthetic `webhook.ping` event used by the `POST /webhooks/:id/test` route.
|
||||
|
||||
## Features
|
||||
|
||||
- `send(endpointId, event, data)` — looks up the endpoint, raises `WEBHOOK_ENDPOINT_NOT_FOUND` / `WEBHOOK_INACTIVE_ENDPOINT` on miss / inactive, signs and delivers via webhook-client.
|
||||
- `sendAll(event, data, filter?)` — fans out to every active endpoint subscribed to `event`; raises `WEBHOOK_EVENT_NOT_SUBSCRIBED` if no endpoint is listening.
|
||||
- `register` / `unregister` / `listEndpoints` — programmatic CRUD wrappers around the configured `EndpointStore`.
|
||||
- `test(endpointId)` — fires a synthetic `webhook.ping` event with `{ requestedAt: ISO8601 }`.
|
||||
- Per-delivery dedup — concurrent `send()` calls with the same `(endpointId, payload.id)` collapse to one delivery. When an `idempotencyGuard` is supplied, calls route through `guard.execute`; otherwise an in-process `Map<string, Promise<DeliveryResult>>` carries the dedup.
|
||||
- Dead-letter — every exhausted delivery records a `DeadLetterEntry` via the configured sink and surfaces `WEBHOOK_DELIVERY_EXHAUSTED` to the caller. Sink write failures surface `WEBHOOK_DEAD_LETTER_WRITE_FAILED` with the sink error as `cause`.
|
||||
- OTel — every code path increments `webhook_plugin.deliveries_total` with the appropriate `status` label (`delivered`, `exhausted`, `not_found`, `inactive`, `invalid`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createWebhookService } from "@suaveplan/api-plugin-webhook/services/webhook-service";
|
||||
import { MemoryEndpointStore } from "@suaveplan/api-plugin-webhook/store/memory";
|
||||
import { MemoryDeadLetterSink } from "@suaveplan/api-plugin-webhook/dead-letter/memory";
|
||||
|
||||
const service = createWebhookService({
|
||||
endpointStore: new MemoryEndpointStore(),
|
||||
deadLetterSink: new MemoryDeadLetterSink(),
|
||||
maxAttempts: 5,
|
||||
initialRetryDelayMs: 500,
|
||||
maxRetryDelayMs: 30_000,
|
||||
idempotencyGuard: null,
|
||||
logger: console as never,
|
||||
clock: () => Date.now(),
|
||||
fetch: globalThis.fetch.bind(globalThis),
|
||||
});
|
||||
|
||||
const endpoint = await service.register({
|
||||
tenantId: null,
|
||||
url: "https://partner.example.com/hooks",
|
||||
secret: "shared-secret",
|
||||
events: ["order.created"],
|
||||
});
|
||||
|
||||
await service.send(endpoint.id, "order.created", { orderId: 42 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Fan-out to every active subscriber, gated by tenant
|
||||
const results = await service.sendAll(
|
||||
"order.created",
|
||||
{ orderId: 42 },
|
||||
{ tenantId: "tenant-a" }
|
||||
);
|
||||
console.log(`${results.length} deliveries fired`);
|
||||
|
||||
// Probe an endpoint without firing real business data
|
||||
const ping = await service.test(endpoint.id);
|
||||
console.log(ping.status, ping.finalStatusCode);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createWebhookService(options)` — factory. Returns a `WebhookService`. Every option is required (the plugin's `onInit` fills defaults before calling).
|
||||
- `WebhookService.send(endpointId, event, data)` — resolves with `DeliveryResult` on success, rejects with `WebhookError` on miss / inactive / exhausted.
|
||||
- `WebhookService.sendAll(event, data, filter?)` — resolves with `readonly DeliveryResult[]`. Empty subscriptions raise `WEBHOOK_EVENT_NOT_SUBSCRIBED`.
|
||||
- `WebhookService.register(input)` — proxy to `EndpointStore.create`. Store errors are translated to `WEBHOOK_STORE_ERROR`.
|
||||
- `WebhookService.unregister(id)` — proxy to `EndpointStore.delete` (idempotent).
|
||||
- `WebhookService.listEndpoints(filter?)` — proxy to `EndpointStore.list`.
|
||||
- `WebhookService.test(endpointId)` — fires a synthetic `webhook.ping`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The dedup key (`${endpointId}:${payload.id}`) was chosen so two distinct payload ids for the same endpoint can deliver concurrently — only same-id sends collapse. This matches the webhook-client contract where `payload.id` IS the delivery identity. The `idempotencyGuard` branch and the in-process branch must produce identical observable behavior — concurrent same-key sends both return the same `DeliveryResult`. The exhaustion path always records the dead-letter entry BEFORE throwing so operators can replay even when the caller's catch block fails to log. The OTel `status` enum carries five values rather than the obvious two (`delivered`/`failed`) so dashboards can distinguish receiver failures (`exhausted`) from misconfiguration (`not_found`/`inactive`) and fan-out edge cases (`invalid`).
|
||||
Reference in New Issue
Block a user