first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -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`).