Initial Commit
This commit is contained in:
13
packages/communication/webhook-client/README.md
Normal file
13
packages/communication/webhook-client/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# `webhook-client`
|
||||
|
||||
Webhook delivery client with retry logic and HMAC verification.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `webhook-client`
|
||||
|
||||
1 package.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/webhook-client`](./webhook-client/README.md) | Webhook delivery client with retry logic, HMAC signature generation/verification, payload transformation, and delivery tracking. Runtime-agnostic (injectable f… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/communication/webhook-client/_intro.md
Normal file
1
packages/communication/webhook-client/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Webhook delivery client with retry logic and HMAC verification.
|
||||
104
packages/communication/webhook-client/webhook-client/README.md
Normal file
104
packages/communication/webhook-client/webhook-client/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# @suaveplan/webhook-client - Webhook delivery client with retry logic, HMAC signature generation/verification, payload transformation, and delivery tracking
|
||||
|
||||
## Summary
|
||||
|
||||
Webhook delivery client with retry logic, HMAC signature generation/verification, payload transformation, and delivery tracking. Runtime-agnostic (injectable fetch).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [webhook-client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [webhook-delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [webhook-client-error](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [payload-builder](./src/utils/payload-builder.md) — Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this…
|
||||
- [signature](./src/utils/signature.md) — Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.s…
|
||||
<!-- end:auto-docs --> - [webhook-client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-client-error](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [payload-builder](./src/utils/payload-builder.md) — Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this…
|
||||
- [signature](./src/utils/signature.md) — Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.s…
|
||||
- [webhook-client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-client-error](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [payload-builder](./src/utils/payload-builder.md) — Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this…
|
||||
- [signature](./src/utils/signature.md) — Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.s…
|
||||
- [webhook-client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-client-error](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [payload-builder](./src/utils/payload-builder.md) — Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this…
|
||||
- [signature](./src/utils/signature.md) — Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.s…
|
||||
- [webhook-client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_ - [webhook-delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [webhook-client-error](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [payload-builder](./src/utils/payload-builder.md) — Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this…
|
||||
- [signature](./src/utils/signature.md) — Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.s…
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/webhook-client
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createWebhookClient } from '@suaveplan/webhook-client';
|
||||
|
||||
const client = createWebhookClient({
|
||||
url: 'https://partner.example.com/webhooks',
|
||||
secret: 'shared-secret',
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
const result = await client.send('order.created', { orderId: 42 });
|
||||
console.log(result.status); // 'delivered'
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| Client | [src/core/client/webhook-client.md](src/core/client/webhook-client.md) | `WebhookClient` class and `createWebhookClient` factory |
|
||||
| Delivery | [src/core/delivery/webhook-delivery.md](src/core/delivery/webhook-delivery.md) | Core HTTP delivery engine with retry logic |
|
||||
| Signature | [src/utils/signature.md](src/utils/signature.md) | HMAC signature generation and verification |
|
||||
| Payload Builder | [src/utils/payload-builder.md](src/utils/payload-builder.md) | Payload construction and deterministic serialization |
|
||||
| Errors | [src/errors/webhook-client-error.md](src/errors/webhook-client-error.md) | Error codes and `WebhookClientError` class |
|
||||
|
||||
## Subpath Exports
|
||||
|
||||
```ts
|
||||
import { createWebhookClient } from '@suaveplan/webhook-client';
|
||||
import { deliver } from '@suaveplan/webhook-client/delivery';
|
||||
import { generateSignature, verifySignature } from '@suaveplan/webhook-client/signature';
|
||||
import { WEBHOOK_CLIENT_CODES, WebhookClientError } from '@suaveplan/webhook-client/errors';
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```ts
|
||||
interface WebhookConfig {
|
||||
url: string;
|
||||
secret?: string; // HMAC secret
|
||||
algorithm?: 'sha256' | 'sha512'; // default: 'sha256'
|
||||
headers?: Record<string, string>; // additional headers
|
||||
timeout?: number; // ms, default: 30000
|
||||
maxRetries?: number; // default: 3
|
||||
retryDelay?: number; // base delay ms, default: 1000
|
||||
}
|
||||
```
|
||||
|
||||
## Retry Behaviour
|
||||
|
||||
Delivery uses exponential backoff with full jitter via `@suaveplan/retry`:
|
||||
|
||||
- **2xx** → success, no retry
|
||||
- **429 / 5xx** → transient, retried up to `maxRetries`
|
||||
- **4xx (except 429)** → terminal, not retried
|
||||
|
||||
## HMAC Signatures
|
||||
|
||||
When `secret` is configured, every delivery attaches:
|
||||
|
||||
- `X-Hub-Signature-256: sha256=<hex>` (default)
|
||||
- `X-Hub-Signature-512: sha512=<hex>` (when `algorithm: 'sha512'`)
|
||||
|
||||
Verification uses Web Crypto API constant-time comparison.
|
||||
|
||||
## OTel Metrics
|
||||
|
||||
| Metric | Type | Labels |
|
||||
|--------|------|--------|
|
||||
| `webhook_client.delivery.total` | Counter | `event`, `status` |
|
||||
| `webhook_client.delivery.duration_ms` | Histogram | `event`, `status` |
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "@suaveplan/webhook-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Webhook delivery client with retry logic, HMAC signature generation/verification, payload transformation, and delivery tracking",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./delivery": {
|
||||
"import": {
|
||||
"types": "./dist/core/delivery/webhook-delivery.d.ts",
|
||||
"default": "./dist/core/delivery/webhook-delivery.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/delivery/webhook-delivery.d.ts",
|
||||
"default": "./dist/core/delivery/webhook-delivery.js"
|
||||
}
|
||||
},
|
||||
"./signature": {
|
||||
"import": {
|
||||
"types": "./dist/utils/signature.d.ts",
|
||||
"default": "./dist/utils/signature.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/signature.d.ts",
|
||||
"default": "./dist/utils/signature.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/webhook-client-error.d.ts",
|
||||
"default": "./dist/errors/webhook-client-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/webhook-client-error.d.ts",
|
||||
"default": "./dist/errors/webhook-client-error.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/retry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createWebhookClient, WebhookClient } from "./webhook-client";
|
||||
@@ -0,0 +1,115 @@
|
||||
# webhook-client
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`WebhookClient` is the primary consumer-facing class for `@suaveplan/webhook-client`. It composes payload construction, HMAC signing, retry-backed HTTP delivery, and incoming signature verification behind a clean stateful API. The immutable `withHeaders` builder allows middleware layers to augment headers without mutating shared configuration, making the client safe to share across request handlers.
|
||||
|
||||
## Features
|
||||
|
||||
- `send(event, data)` — builds a payload and delivers it in one call
|
||||
- `sendPayload(payload)` — delivers a pre-built payload (useful with idempotency guards)
|
||||
- `verify(payload, signature)` — verifies an incoming webhook signature using this client's secret
|
||||
- `withHeaders(headers)` — returns a new client with merged headers (immutable builder pattern)
|
||||
- `createWebhookClient(config, options?)` — factory function (preferred over `new`)
|
||||
- OTel metrics emitted by the underlying `deliver` function automatically
|
||||
- Injectable `fetch` via `DeliveryOptions` for test isolation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWebhookClient } from '@suaveplan/webhook-client';
|
||||
|
||||
const client = createWebhookClient({
|
||||
url: 'https://partner.example.com/webhooks',
|
||||
secret: 'shared-secret',
|
||||
});
|
||||
|
||||
const result = await client.send('order.created', { orderId: 42 });
|
||||
console.log(result.status); // 'delivered'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createWebhookClient } from '@suaveplan/webhook-client';
|
||||
|
||||
// Base client shared across the service
|
||||
const base = createWebhookClient({
|
||||
url: 'https://partner.example.com/webhooks',
|
||||
secret: process.env.WEBHOOK_SECRET,
|
||||
algorithm: 'sha256',
|
||||
timeout: 10000,
|
||||
maxRetries: 5,
|
||||
retryDelay: 500,
|
||||
});
|
||||
|
||||
// Per-request client with trace headers injected from incoming request
|
||||
const perRequest = base.withHeaders({
|
||||
'X-Trace-Id': ctx.traceId,
|
||||
'X-Request-Id': ctx.requestId,
|
||||
});
|
||||
|
||||
const result = await perRequest.send('payment.processed', {
|
||||
invoiceId: ctx.invoiceId,
|
||||
amount: ctx.amount,
|
||||
});
|
||||
|
||||
// Verify incoming webhook from same partner
|
||||
const incomingBody = await request.text();
|
||||
const incomingSig = request.headers.get('X-Hub-Signature-256') ?? '';
|
||||
const valid = await base.verify(incomingBody, incomingSig);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createWebhookClient(config, options?)`
|
||||
|
||||
```ts
|
||||
function createWebhookClient(
|
||||
config: WebhookConfig,
|
||||
options?: DeliveryOptions
|
||||
): WebhookClient
|
||||
```
|
||||
|
||||
Factory function. Prefer this over `new WebhookClient(...)`.
|
||||
|
||||
### `WebhookClient`
|
||||
|
||||
#### `send(event, data)`
|
||||
|
||||
```ts
|
||||
async send(event: string, data: unknown): Promise<DeliveryResult>
|
||||
```
|
||||
|
||||
Constructs a `WebhookPayload` via `buildPayload` and delivers it. Returns a `DeliveryResult` with the full attempt history.
|
||||
|
||||
#### `sendPayload(payload)`
|
||||
|
||||
```ts
|
||||
async sendPayload(payload: WebhookPayload): Promise<DeliveryResult>
|
||||
```
|
||||
|
||||
Delivers a pre-built payload. Useful when you need to inject a specific `id` from an idempotency guard.
|
||||
|
||||
#### `verify(payload, signature)`
|
||||
|
||||
```ts
|
||||
async verify(payload: string, signature: string): Promise<boolean>
|
||||
```
|
||||
|
||||
Verifies an incoming webhook signature against the client's configured `secret` and `algorithm`. Returns `false` if no secret is configured.
|
||||
|
||||
#### `withHeaders(headers)`
|
||||
|
||||
```ts
|
||||
withHeaders(headers: Record<string, string>): WebhookClient
|
||||
```
|
||||
|
||||
Returns a **new** `WebhookClient` with `headers` merged into the existing config. The original instance is unchanged.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`WebhookClient` uses `protected` rather than `private` for `config` and `options` to allow subclassing for specialized clients (e.g. a `SignedWebhookClient` that enforces secret presence). The `withHeaders` method creates a shallow merge — later-passed headers override earlier ones — matching standard HTTP header override semantics. The `verify` method returns `false` when no secret is configured rather than throwing, since receiving an unsigned request in that context is ambiguous and callers should handle it explicitly.
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildPayload, serializePayload } from "../../utils/payload-builder.js";
|
||||
import { generateSignature } from "../../utils/signature.js";
|
||||
import { createWebhookClient, WebhookClient } from "./webhook-client.js";
|
||||
|
||||
const makeOkFetch =
|
||||
(): ((url: string, init: RequestInit) => Promise<Response>) => async () =>
|
||||
({ ok: true, status: 200, statusText: "OK" }) as Response;
|
||||
|
||||
describe("createWebhookClient", () => {
|
||||
it("returns a WebhookClient instance", { timeout: 5000 }, () => {
|
||||
const client = createWebhookClient({ url: "https://example.com" });
|
||||
expect(client).toBeInstanceOf(WebhookClient);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebhookClient.send", () => {
|
||||
it("delivers an event and returns DeliveryResult", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const client = createWebhookClient(
|
||||
{ url: "https://example.com" },
|
||||
{ fetch: makeOkFetch() }
|
||||
);
|
||||
const result = await client.send("order.created", { orderId: 1 });
|
||||
expect(result.status).toBe("delivered");
|
||||
expect(result.payload.event).toBe("order.created");
|
||||
});
|
||||
|
||||
it("passes data through to the payload", { timeout: 30000 }, async () => {
|
||||
const client = createWebhookClient(
|
||||
{ url: "https://example.com" },
|
||||
{ fetch: makeOkFetch() }
|
||||
);
|
||||
const result = await client.send("payment.processed", { amount: 99 });
|
||||
expect(result.payload.data).toEqual({ amount: 99 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebhookClient.sendPayload", () => {
|
||||
it("delivers a pre-built payload", { timeout: 30000 }, async () => {
|
||||
const client = createWebhookClient(
|
||||
{ url: "https://example.com" },
|
||||
{ fetch: makeOkFetch() }
|
||||
);
|
||||
const payload = buildPayload(
|
||||
"custom.event",
|
||||
{ custom: true },
|
||||
"fixed-id"
|
||||
);
|
||||
const result = await client.sendPayload(payload);
|
||||
expect(result.id).toBe("fixed-id");
|
||||
expect(result.status).toBe("delivered");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebhookClient.verify", () => {
|
||||
it("returns true for valid signature", { timeout: 30000 }, async () => {
|
||||
const client = createWebhookClient({
|
||||
url: "https://example.com",
|
||||
secret: "mysecret",
|
||||
});
|
||||
const body = serializePayload(buildPayload("evt", {}));
|
||||
const sig = await generateSignature(body, "mysecret", "sha256");
|
||||
const valid = await client.verify(body, sig);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid signature", { timeout: 30000 }, async () => {
|
||||
const client = createWebhookClient({
|
||||
url: "https://example.com",
|
||||
secret: "mysecret",
|
||||
});
|
||||
const valid = await client.verify("body", "sha256=invalid");
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no secret configured", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const client = createWebhookClient({ url: "https://example.com" });
|
||||
const valid = await client.verify("body", "sha256=abc123");
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebhookClient.withHeaders", () => {
|
||||
it("returns a new WebhookClient instance", { timeout: 5000 }, () => {
|
||||
const original = createWebhookClient({ url: "https://example.com" });
|
||||
const derived = original.withHeaders({ "X-Custom": "value" });
|
||||
expect(derived).toBeInstanceOf(WebhookClient);
|
||||
expect(derived).not.toBe(original);
|
||||
});
|
||||
|
||||
it("merges headers into new client", { timeout: 30000 }, async () => {
|
||||
let capturedHeaders: Record<string, string> | undefined;
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
init: RequestInit
|
||||
): Promise<Response> => {
|
||||
capturedHeaders = init.headers as Record<string, string>;
|
||||
return { ok: true, status: 200, statusText: "OK" } as Response;
|
||||
};
|
||||
|
||||
const client = createWebhookClient(
|
||||
{
|
||||
url: "https://example.com",
|
||||
headers: { "X-Existing": "old" },
|
||||
},
|
||||
{ fetch: fetchFn }
|
||||
);
|
||||
|
||||
const derived = client.withHeaders({ "X-New": "new-value" });
|
||||
await derived.send("evt", {});
|
||||
|
||||
expect(capturedHeaders?.["X-Existing"]).toBe("old");
|
||||
expect(capturedHeaders?.["X-New"]).toBe("new-value");
|
||||
});
|
||||
|
||||
it("does not mutate the original client headers", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const headers1: Record<string, string> = {};
|
||||
const headers2: Record<string, string> = {};
|
||||
|
||||
const makeFetch =
|
||||
(target: Record<string, string>) =>
|
||||
async (_url: string, init: RequestInit): Promise<Response> => {
|
||||
Object.assign(target, init.headers);
|
||||
return { ok: true, status: 200, statusText: "OK" } as Response;
|
||||
};
|
||||
|
||||
const original = createWebhookClient(
|
||||
{ url: "https://example.com" },
|
||||
{ fetch: makeFetch(headers1) }
|
||||
);
|
||||
|
||||
const derived = original.withHeaders({ "X-Extra": "value" });
|
||||
// Deliver from both using their own fetch
|
||||
const _derivedWithFetch = derived.withHeaders({});
|
||||
// just test that original doesn't have X-Extra
|
||||
const origCopy = createWebhookClient(
|
||||
{ url: "https://example.com" },
|
||||
{ fetch: makeFetch(headers2) }
|
||||
);
|
||||
await origCopy.send("evt", {});
|
||||
expect(headers2["X-Extra"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("later headers override earlier ones", { timeout: 30000 }, async () => {
|
||||
let capturedHeaders: Record<string, string> | undefined;
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
init: RequestInit
|
||||
): Promise<Response> => {
|
||||
capturedHeaders = init.headers as Record<string, string>;
|
||||
return { ok: true, status: 200, statusText: "OK" } as Response;
|
||||
};
|
||||
|
||||
const client = createWebhookClient(
|
||||
{ url: "https://example.com", headers: { "X-Version": "1" } },
|
||||
{ fetch: fetchFn }
|
||||
);
|
||||
|
||||
await client.withHeaders({ "X-Version": "2" }).send("evt", {});
|
||||
expect(capturedHeaders?.["X-Version"]).toBe("2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
DeliveryResult,
|
||||
WebhookConfig,
|
||||
WebhookPayload,
|
||||
} from "../../types/webhook-client.types.js";
|
||||
import { buildPayload } from "../../utils/payload-builder.js";
|
||||
import { verifySignature } from "../../utils/signature.js";
|
||||
import { type DeliveryOptions, deliver } from "../delivery/webhook-delivery.js";
|
||||
|
||||
/**
|
||||
* Outgoing webhook delivery client.
|
||||
*
|
||||
* Wraps the `deliver` function with a stateful config, providing a clean
|
||||
* builder API for header composition and convenience methods for sending
|
||||
* events and verifying incoming signatures.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const client = createWebhookClient({
|
||||
* url: 'https://partner.example.com/webhooks',
|
||||
* secret: 'shared-secret',
|
||||
* });
|
||||
*
|
||||
* await client.send('order.created', { orderId: 42 });
|
||||
* ```
|
||||
*/
|
||||
export class WebhookClient {
|
||||
protected readonly config: WebhookConfig;
|
||||
protected readonly options: DeliveryOptions;
|
||||
|
||||
constructor(config: WebhookConfig, options: DeliveryOptions = {}) {
|
||||
this.config = config;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a `WebhookPayload` for the given event and data, then delivers it.
|
||||
*
|
||||
* @param event - Event type discriminator, e.g. `"order.created"`.
|
||||
* @param data - Arbitrary event payload data.
|
||||
* @returns A `DeliveryResult` with full attempt history.
|
||||
*/
|
||||
async send(event: string, data: unknown): Promise<DeliveryResult> {
|
||||
const payload = buildPayload(event, data);
|
||||
return deliver(this.config, payload, this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers a pre-built `WebhookPayload` directly.
|
||||
*
|
||||
* Use this when you need to inject a specific `id` (e.g. from an idempotency
|
||||
* guard) or control the timestamp.
|
||||
*
|
||||
* @param payload - A pre-constructed `WebhookPayload`.
|
||||
* @returns A `DeliveryResult` with full attempt history.
|
||||
*/
|
||||
async sendPayload(payload: WebhookPayload): Promise<DeliveryResult> {
|
||||
return deliver(this.config, payload, this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an incoming webhook signature against this client's configured secret.
|
||||
*
|
||||
* Convenience wrapper around `verifySignature` — useful when the same
|
||||
* endpoint acts as both sender and receiver.
|
||||
*
|
||||
* @param payload - The raw serialized payload string received.
|
||||
* @param signature - The signature header value (e.g. `"sha256=abc..."`).
|
||||
* @returns `true` if the signature is valid, `false` otherwise.
|
||||
*/
|
||||
async verify(payload: string, signature: string): Promise<boolean> {
|
||||
if (!this.config.secret) return false;
|
||||
return verifySignature(
|
||||
payload,
|
||||
signature,
|
||||
this.config.secret,
|
||||
this.config.algorithm ?? "sha256"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new `WebhookClient` instance with the provided headers merged
|
||||
* into the existing config headers.
|
||||
*
|
||||
* The original instance is unchanged (immutable builder pattern).
|
||||
*
|
||||
* @param headers - Additional headers to merge.
|
||||
* @returns A new `WebhookClient` with merged headers.
|
||||
*/
|
||||
withHeaders(headers: Record<string, string>): WebhookClient {
|
||||
return new WebhookClient(
|
||||
{
|
||||
...this.config,
|
||||
headers: { ...(this.config.headers ?? {}), ...headers },
|
||||
},
|
||||
this.options
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for creating a `WebhookClient`.
|
||||
*
|
||||
* Preferred over `new WebhookClient(...)` for cleaner call sites.
|
||||
*
|
||||
* @param config - Webhook endpoint configuration.
|
||||
* @param options - Optional injectable fetch and attempt callback.
|
||||
* @returns A configured `WebhookClient` instance.
|
||||
*/
|
||||
export function createWebhookClient(
|
||||
config: WebhookConfig,
|
||||
options?: DeliveryOptions
|
||||
): WebhookClient {
|
||||
return new WebhookClient(config, options);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type DeliveryOptions, deliver } from "./webhook-delivery";
|
||||
@@ -0,0 +1,98 @@
|
||||
# webhook-delivery
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the core HTTP delivery engine for `@suaveplan/webhook-client`. It handles payload serialization, HMAC signature attachment, exponential-backoff retry (via `@suaveplan/retry`), per-attempt tracking, and OTel metric emission. The fetch implementation is injectable, making the module fully testable without network access or mocking frameworks.
|
||||
|
||||
## Features
|
||||
|
||||
- `deliver` — async function that POSTs a `WebhookPayload` to a configured URL with retry logic
|
||||
- Exponential backoff with full jitter via `@suaveplan/retry`'s `createExponentialBackoff`
|
||||
- HMAC signature attached as `X-Hub-Signature-256` or `X-Hub-Signature-512` when a secret is configured
|
||||
- Standard webhook headers: `X-Webhook-Id`, `X-Webhook-Event`, `X-Webhook-Timestamp`
|
||||
- Per-attempt callback (`onAttempt`) for observability hooks
|
||||
- Per-request AbortSignal timeout
|
||||
- Retry logic distinguishes retryable (429, 5xx, network errors) from terminal (4xx except 429) failures
|
||||
- OTel counter `webhook_client.delivery.total{event, status}` and histogram `webhook_client.delivery.duration_ms{event, status}`
|
||||
- Injectable `fetch` for test isolation without mocks
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { deliver } from '@suaveplan/webhook-client/delivery';
|
||||
import { buildPayload } from '@suaveplan/webhook-client';
|
||||
|
||||
const payload = buildPayload('order.created', { orderId: 42 });
|
||||
|
||||
const result = await deliver(
|
||||
{ url: 'https://partner.example.com/webhooks', secret: 'my-secret' },
|
||||
payload
|
||||
);
|
||||
|
||||
console.log(result.status); // 'delivered'
|
||||
console.log(result.attempts.length); // 1 (no retries needed)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { deliver } from '@suaveplan/webhook-client/delivery';
|
||||
import { buildPayload } from '@suaveplan/webhook-client';
|
||||
|
||||
const payload = buildPayload('payment.failed', { invoiceId: 'inv-001' });
|
||||
|
||||
const result = await deliver(
|
||||
{
|
||||
url: 'https://partner.example.com/webhooks',
|
||||
secret: 'shared-secret',
|
||||
algorithm: 'sha512',
|
||||
timeout: 5000,
|
||||
maxRetries: 5,
|
||||
retryDelay: 500,
|
||||
headers: { 'X-Source': 'billing-service' },
|
||||
},
|
||||
payload,
|
||||
{
|
||||
onAttempt: (attempt) => {
|
||||
console.log(`Attempt ${attempt.attemptNumber}: ${attempt.success ? 'ok' : attempt.error}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DeliveryOptions`
|
||||
|
||||
```ts
|
||||
interface DeliveryOptions {
|
||||
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
||||
onAttempt?: (attempt: DeliveryAttempt) => void;
|
||||
}
|
||||
```
|
||||
|
||||
- `fetch` — injectable fetch implementation; defaults to `globalThis.fetch`
|
||||
- `onAttempt` — callback fired after each attempt with the attempt record
|
||||
|
||||
### `deliver(config, payload, options?)`
|
||||
|
||||
```ts
|
||||
async function deliver(
|
||||
config: WebhookConfig,
|
||||
payload: WebhookPayload,
|
||||
options?: DeliveryOptions
|
||||
): Promise<DeliveryResult>
|
||||
```
|
||||
|
||||
- `config` — endpoint configuration including URL, secret, timeouts, retry settings
|
||||
- `payload` — pre-built `WebhookPayload` (use `buildPayload` to construct one)
|
||||
- `options` — optional injectable fetch and attempt callback
|
||||
- Returns a `DeliveryResult` with full attempt history and final status
|
||||
- Throws `WebhookClientError` (code `DELIVERY_FAILED`) if all retries are exhausted
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The signature is computed once before the retry loop because HMAC output is deterministic for the same payload and secret — re-computing per attempt would be wasteful. The `shouldRetry` predicate in the retry config marks non-retryable 4xx errors as terminal by attaching a `_terminal` flag to the thrown error; this avoids wasting retry budget on errors that will never resolve. OTel metrics are lazily initialised on the first call to keep the module side-effect-free at import time.
|
||||
@@ -0,0 +1,484 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WebhookClientError } from "../../errors/webhook-client-error.js";
|
||||
import type { DeliveryAttempt } from "../../types/webhook-client.types.js";
|
||||
import { buildPayload } from "../../utils/payload-builder.js";
|
||||
import { deliver } from "./webhook-delivery.js";
|
||||
|
||||
const makeOkFetch =
|
||||
(status = 200): ((url: string, init: RequestInit) => Promise<Response>) =>
|
||||
async (_url: string, _init: RequestInit) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: "OK",
|
||||
}) as Response;
|
||||
|
||||
const makeFailFetch =
|
||||
(
|
||||
status: number,
|
||||
statusText = "Error"
|
||||
): ((url: string, init: RequestInit) => Promise<Response>) =>
|
||||
async (_url: string, _init: RequestInit) =>
|
||||
({ ok: false, status, statusText }) as Response;
|
||||
|
||||
const makeThrowFetch =
|
||||
(
|
||||
message = "Network error"
|
||||
): ((url: string, init: RequestInit) => Promise<Response>) =>
|
||||
async (_url: string, _init: RequestInit) => {
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
describe("deliver", () => {
|
||||
it("returns delivered status on 200 response", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const payload = buildPayload("test.event", { x: 1 });
|
||||
const result = await deliver(
|
||||
{ url: "https://example.com/webhook", maxRetries: 1 },
|
||||
payload,
|
||||
{ fetch: makeOkFetch(200) }
|
||||
);
|
||||
|
||||
expect(result.status).toBe("delivered");
|
||||
expect(result.id).toBe(payload.id);
|
||||
expect(result.payload).toEqual(payload);
|
||||
expect(result.finalStatusCode).toBe(200);
|
||||
expect(result.attempts).toHaveLength(1);
|
||||
expect(result.attempts[0].success).toBe(true);
|
||||
});
|
||||
|
||||
it("attaches HMAC signature header when secret is provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let capturedHeaders: Record<string, string> | undefined;
|
||||
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
init: RequestInit
|
||||
): Promise<Response> => {
|
||||
capturedHeaders = init.headers as Record<string, string>;
|
||||
return { ok: true, status: 200, statusText: "OK" } as Response;
|
||||
};
|
||||
|
||||
const payload = buildPayload("signed.event", { data: true });
|
||||
await deliver(
|
||||
{ url: "https://example.com", secret: "mysecret" },
|
||||
payload,
|
||||
{ fetch: fetchFn }
|
||||
);
|
||||
|
||||
expect(capturedHeaders?.["X-Hub-Signature-256"]).toMatch(
|
||||
/^sha256=[0-9a-f]+$/
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches sha512 signature header when algorithm is sha512", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let capturedHeaders: Record<string, string> | undefined;
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
init: RequestInit
|
||||
): Promise<Response> => {
|
||||
capturedHeaders = init.headers as Record<string, string>;
|
||||
return { ok: true, status: 200, statusText: "OK" } as Response;
|
||||
};
|
||||
|
||||
const payload = buildPayload("evt", {});
|
||||
await deliver(
|
||||
{ url: "https://example.com", secret: "s", algorithm: "sha512" },
|
||||
payload,
|
||||
{ fetch: fetchFn }
|
||||
);
|
||||
|
||||
expect(capturedHeaders?.["X-Hub-Signature-512"]).toMatch(
|
||||
/^sha512=[0-9a-f]+$/
|
||||
);
|
||||
});
|
||||
|
||||
it("sends standard webhook headers", { timeout: 30000 }, async () => {
|
||||
let capturedHeaders: Record<string, string> | undefined;
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
init: RequestInit
|
||||
): Promise<Response> => {
|
||||
capturedHeaders = init.headers as Record<string, string>;
|
||||
return { ok: true, status: 200, statusText: "OK" } as Response;
|
||||
};
|
||||
|
||||
const payload = buildPayload("order.created", { id: 1 });
|
||||
await deliver({ url: "https://example.com" }, payload, {
|
||||
fetch: fetchFn,
|
||||
});
|
||||
|
||||
expect(capturedHeaders?.["Content-Type"]).toBe("application/json");
|
||||
expect(capturedHeaders?.["X-Webhook-Id"]).toBe(payload.id);
|
||||
expect(capturedHeaders?.["X-Webhook-Event"]).toBe("order.created");
|
||||
expect(capturedHeaders?.["X-Webhook-Timestamp"]).toBe(
|
||||
payload.timestamp
|
||||
);
|
||||
});
|
||||
|
||||
it("merges additional config headers", { timeout: 30000 }, async () => {
|
||||
let capturedHeaders: Record<string, string> | undefined;
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
init: RequestInit
|
||||
): Promise<Response> => {
|
||||
capturedHeaders = init.headers as Record<string, string>;
|
||||
return { ok: true, status: 200, statusText: "OK" } as Response;
|
||||
};
|
||||
|
||||
const payload = buildPayload("evt", {});
|
||||
await deliver(
|
||||
{ url: "https://example.com", headers: { "X-Custom": "my-value" } },
|
||||
payload,
|
||||
{ fetch: fetchFn }
|
||||
);
|
||||
|
||||
expect(capturedHeaders?.["X-Custom"]).toBe("my-value");
|
||||
});
|
||||
|
||||
it("calls onAttempt for each attempt", { timeout: 30000 }, async () => {
|
||||
const collected: DeliveryAttempt[] = [];
|
||||
const payload = buildPayload("evt", {});
|
||||
|
||||
await deliver({ url: "https://example.com", maxRetries: 1 }, payload, {
|
||||
fetch: makeOkFetch(200),
|
||||
onAttempt: (a) => collected.push(a),
|
||||
});
|
||||
|
||||
expect(collected).toHaveLength(1);
|
||||
expect(collected[0].success).toBe(true);
|
||||
});
|
||||
|
||||
it("throws WebhookClientError after all retries exhausted", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const payload = buildPayload("evt", {});
|
||||
await expect(
|
||||
deliver(
|
||||
{ url: "https://example.com", maxRetries: 2, retryDelay: 1 },
|
||||
payload,
|
||||
{ fetch: makeFailFetch(500) }
|
||||
)
|
||||
).rejects.toThrow(WebhookClientError);
|
||||
});
|
||||
|
||||
it("does not retry on 400 (client error)", { timeout: 30000 }, async () => {
|
||||
let callCount = 0;
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
_init: RequestInit
|
||||
): Promise<Response> => {
|
||||
callCount++;
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
} as Response;
|
||||
};
|
||||
|
||||
const payload = buildPayload("evt", {});
|
||||
await expect(
|
||||
deliver(
|
||||
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
|
||||
payload,
|
||||
{ fetch: fetchFn }
|
||||
)
|
||||
).rejects.toThrow(WebhookClientError);
|
||||
|
||||
// Should only attempt once since 400 is non-retryable
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("records multiple attempts on retryable failure", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let callCount = 0;
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
_init: RequestInit
|
||||
): Promise<Response> => {
|
||||
callCount++;
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Server Error",
|
||||
} as Response;
|
||||
};
|
||||
|
||||
const payload = buildPayload("evt", {});
|
||||
await expect(
|
||||
deliver(
|
||||
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
|
||||
payload,
|
||||
{ fetch: fetchFn }
|
||||
)
|
||||
).rejects.toThrow(WebhookClientError);
|
||||
|
||||
expect(callCount).toBe(3);
|
||||
});
|
||||
|
||||
it("handles network error (fetch throws)", { timeout: 30000 }, async () => {
|
||||
const payload = buildPayload("evt", {});
|
||||
await expect(
|
||||
deliver(
|
||||
{ url: "https://example.com", maxRetries: 2, retryDelay: 1 },
|
||||
payload,
|
||||
{ fetch: makeThrowFetch("ECONNREFUSED") }
|
||||
)
|
||||
).rejects.toThrow(WebhookClientError);
|
||||
});
|
||||
|
||||
it("includes finalStatusCode in result", { timeout: 30000 }, async () => {
|
||||
const payload = buildPayload("evt", {});
|
||||
const result = await deliver(
|
||||
{ url: "https://example.com", maxRetries: 1 },
|
||||
payload,
|
||||
{ fetch: makeOkFetch(201) }
|
||||
);
|
||||
expect(result.finalStatusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("omits finalStatusCode when fetch always throws (no HTTP response)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const payload = buildPayload("evt", {});
|
||||
await expect(
|
||||
deliver(
|
||||
{ url: "https://example.com", maxRetries: 1, retryDelay: 1 },
|
||||
payload,
|
||||
{ fetch: makeThrowFetch("Connection refused") }
|
||||
)
|
||||
).rejects.toThrow(WebhookClientError);
|
||||
});
|
||||
|
||||
it("reuses metrics objects across multiple deliveries (caches after first call)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Call deliver twice to exercise the getMetrics() cache branch
|
||||
const payload1 = buildPayload("evt.first", {});
|
||||
const payload2 = buildPayload("evt.second", {});
|
||||
const result1 = await deliver(
|
||||
{ url: "https://example.com", maxRetries: 1 },
|
||||
payload1,
|
||||
{ fetch: makeOkFetch(200) }
|
||||
);
|
||||
const result2 = await deliver(
|
||||
{ url: "https://example.com", maxRetries: 1 },
|
||||
payload2,
|
||||
{ fetch: makeOkFetch(200) }
|
||||
);
|
||||
expect(result1.status).toBe("delivered");
|
||||
expect(result2.status).toBe("delivered");
|
||||
});
|
||||
|
||||
it("uses error message fallback when statusCode is undefined in retryable path", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// This exercises the `result.attempt.error ?? \`HTTP ...\`` fallback
|
||||
// by throwing from fetch (no statusCode) then retrying
|
||||
let calls = 0;
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
_init: RequestInit
|
||||
): Promise<Response> => {
|
||||
calls++;
|
||||
if (calls < 2) {
|
||||
throw new Error("transient network error");
|
||||
}
|
||||
return { ok: true, status: 200, statusText: "OK" } as Response;
|
||||
};
|
||||
|
||||
const payload = buildPayload("evt", {});
|
||||
const result = await deliver(
|
||||
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
|
||||
payload,
|
||||
{ fetch: fetchFn }
|
||||
);
|
||||
expect(result.status).toBe("delivered");
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("handles fetch throwing a non-Error (string) gracefully", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
_init: RequestInit
|
||||
): Promise<Response> => {
|
||||
// Throw a plain object (non-Error) to exercise String(err) fallback path
|
||||
throw { code: "non-error-fetch-failure" };
|
||||
};
|
||||
|
||||
const payload = buildPayload("evt", {});
|
||||
await expect(
|
||||
deliver(
|
||||
{ url: "https://example.com", maxRetries: 1, retryDelay: 1 },
|
||||
payload,
|
||||
{ fetch: fetchFn }
|
||||
)
|
||||
).rejects.toThrow(WebhookClientError);
|
||||
});
|
||||
|
||||
it("retries on 429 Too Many Requests", { timeout: 30000 }, async () => {
|
||||
let callCount = 0;
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
_init: RequestInit
|
||||
): Promise<Response> => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
} as Response;
|
||||
}
|
||||
return { ok: true, status: 200, statusText: "OK" } as Response;
|
||||
};
|
||||
|
||||
const payload = buildPayload("evt", {});
|
||||
const result = await deliver(
|
||||
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
|
||||
payload,
|
||||
{ fetch: fetchFn }
|
||||
);
|
||||
expect(result.status).toBe("delivered");
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("treats 1xx informational as non-retryable failure", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Exercises the statusCode < 200 branch of isRetryableStatus
|
||||
let callCount = 0;
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
_init: RequestInit
|
||||
): Promise<Response> => {
|
||||
callCount++;
|
||||
return {
|
||||
ok: false,
|
||||
status: 100,
|
||||
statusText: "Continue",
|
||||
} as Response;
|
||||
};
|
||||
|
||||
const payload = buildPayload("evt", {});
|
||||
await expect(
|
||||
deliver(
|
||||
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
|
||||
payload,
|
||||
{ fetch: fetchFn }
|
||||
)
|
||||
).rejects.toThrow(WebhookClientError);
|
||||
// 1xx is non-retryable
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("treats 300 redirect as non-retryable failure", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let callCount = 0;
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
_init: RequestInit
|
||||
): Promise<Response> => {
|
||||
callCount++;
|
||||
return {
|
||||
ok: false,
|
||||
status: 300,
|
||||
statusText: "Multiple Choices",
|
||||
} as Response;
|
||||
};
|
||||
|
||||
const payload = buildPayload("evt", {});
|
||||
await expect(
|
||||
deliver(
|
||||
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
|
||||
payload,
|
||||
{ fetch: fetchFn }
|
||||
)
|
||||
).rejects.toThrow(WebhookClientError);
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("uses globalThis.fetch when no fetch option is provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Temporarily set globalThis.fetch to a mock for this test
|
||||
const g = globalThis as { fetch?: unknown };
|
||||
const saved = g.fetch;
|
||||
const capturedCalls: string[] = [];
|
||||
g.fetch = async (
|
||||
url: string,
|
||||
_init: RequestInit
|
||||
): Promise<Response> => {
|
||||
capturedCalls.push(String(url));
|
||||
return { ok: true, status: 200, statusText: "OK" } as Response;
|
||||
};
|
||||
try {
|
||||
const payload = buildPayload("evt", {});
|
||||
const result = await deliver(
|
||||
{ url: "https://example.com/fallback-fetch", maxRetries: 1 },
|
||||
payload
|
||||
// Note: no fetch option — uses globalThis.fetch
|
||||
);
|
||||
expect(result.status).toBe("delivered");
|
||||
expect(capturedCalls).toContain(
|
||||
"https://example.com/fallback-fetch"
|
||||
);
|
||||
} finally {
|
||||
g.fetch = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it("records timeout error when per-request timeout fires", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Fetch that waits longer than the configured timeout, allowing the
|
||||
// AbortController timer to fire and abort the request.
|
||||
const fetchFn = async (
|
||||
_url: string,
|
||||
init: RequestInit
|
||||
): Promise<Response> => {
|
||||
const signal = init.signal as AbortSignal;
|
||||
return new Promise<Response>((_, reject) => {
|
||||
const onAbort = () =>
|
||||
reject(
|
||||
new DOMException(
|
||||
"The operation was aborted.",
|
||||
"AbortError"
|
||||
)
|
||||
);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
const payload = buildPayload("evt", {});
|
||||
const collected: import("../../types/webhook-client.types.js").DeliveryAttempt[] =
|
||||
[];
|
||||
await expect(
|
||||
deliver(
|
||||
{
|
||||
url: "https://example.com",
|
||||
maxRetries: 1,
|
||||
retryDelay: 1,
|
||||
timeout: 10,
|
||||
},
|
||||
payload,
|
||||
{
|
||||
fetch: fetchFn,
|
||||
onAttempt: (a) => collected.push(a),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow(WebhookClientError);
|
||||
// The attempt error should indicate timeout
|
||||
expect(collected.some((a) => a.error === "Request timed out")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { Counter, Histogram } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { createExponentialBackoff, retry } from "@suaveplan/retry";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import {
|
||||
WEBHOOK_CLIENT_CODES,
|
||||
WebhookClientError,
|
||||
} from "../../errors/webhook-client-error.js";
|
||||
import type {
|
||||
DeliveryAttempt,
|
||||
DeliveryResult,
|
||||
DeliveryStatus,
|
||||
WebhookConfig,
|
||||
WebhookPayload,
|
||||
} from "../../types/webhook-client.types.js";
|
||||
import { serializePayload } from "../../utils/payload-builder.js";
|
||||
import { generateSignature } from "../../utils/signature.js";
|
||||
|
||||
/**
|
||||
* Options for a single delivery operation.
|
||||
*/
|
||||
export interface DeliveryOptions {
|
||||
/**
|
||||
* Injectable fetch implementation. Defaults to `globalThis.fetch`.
|
||||
* Use this to provide a custom fetch in test environments.
|
||||
*/
|
||||
fetch?: FetchLike;
|
||||
/** Callback invoked after each delivery attempt (success or failure). */
|
||||
onAttempt?: (attempt: DeliveryAttempt) => void;
|
||||
}
|
||||
|
||||
/** Marker interface to tag terminal (non-retryable) errors */
|
||||
interface TerminalError extends WebhookClientError {
|
||||
_terminal: boolean;
|
||||
}
|
||||
|
||||
/** OTel metric holders — lazily initialised once per module load. */
|
||||
let deliveryCounter: Counter | undefined;
|
||||
let durationHistogram: Histogram | undefined;
|
||||
|
||||
function getMetrics(): { counter: Counter; histogram: Histogram } {
|
||||
if (!deliveryCounter || !durationHistogram) {
|
||||
const meter = metrics.getMeter("@suaveplan/webhook-client");
|
||||
deliveryCounter = meter.createCounter("webhook_client.delivery.total", {
|
||||
description:
|
||||
"Total webhook delivery attempts by event and final status",
|
||||
});
|
||||
durationHistogram = meter.createHistogram(
|
||||
"webhook_client.delivery.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Total time spent on webhook delivery (including retries) in ms",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
}
|
||||
return { counter: deliveryCounter, histogram: durationHistogram };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an HTTP status code should be retried.
|
||||
*
|
||||
* Called only on non-success (non-2xx) responses.
|
||||
* - 429 = rate-limited (retry)
|
||||
* - 5xx = server error (retry)
|
||||
* - All other non-success (1xx, 3xx, 4xx except 429) = terminal failure (do not retry)
|
||||
*/
|
||||
function isRetryableStatus(statusCode: number): boolean {
|
||||
if (statusCode === 429) return true;
|
||||
if (statusCode >= 500) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a single HTTP POST attempt and returns a partial `DeliveryAttempt`.
|
||||
*/
|
||||
async function executeAttempt(
|
||||
config: WebhookConfig,
|
||||
body: string,
|
||||
signature: string | null,
|
||||
attemptNumber: number,
|
||||
fetchFn: (url: string, init: RequestInit) => Promise<Response>,
|
||||
payload: WebhookPayload
|
||||
): Promise<{ attempt: DeliveryAttempt; response: Response | null }> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Id": payload.id,
|
||||
"X-Webhook-Event": payload.event,
|
||||
"X-Webhook-Timestamp": payload.timestamp,
|
||||
...(config.headers ?? {}),
|
||||
};
|
||||
|
||||
if (signature !== null) {
|
||||
const algo = config.algorithm ?? "sha256";
|
||||
const headerName =
|
||||
algo === "sha512" ? "X-Hub-Signature-512" : "X-Hub-Signature-256";
|
||||
headers[headerName] = signature;
|
||||
}
|
||||
|
||||
const timeout = config.timeout ?? 30000;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
const started = Date.now();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const response = await fetchFn(config.url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
const duration = Date.now() - started;
|
||||
const success = response.ok;
|
||||
|
||||
return {
|
||||
response,
|
||||
attempt: {
|
||||
attemptNumber,
|
||||
timestamp,
|
||||
statusCode: response.status,
|
||||
success,
|
||||
...(success
|
||||
? {}
|
||||
: {
|
||||
error: `HTTP ${response.status} ${response.statusText}`,
|
||||
}),
|
||||
duration,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
const duration = Date.now() - started;
|
||||
const isTimeout =
|
||||
err instanceof DOMException && err.name === "AbortError";
|
||||
|
||||
return {
|
||||
response: null,
|
||||
attempt: {
|
||||
attemptNumber,
|
||||
timestamp,
|
||||
success: false,
|
||||
error: isTimeout
|
||||
? "Request timed out"
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err),
|
||||
duration,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers a webhook payload to the configured endpoint with retry logic.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Serializes the payload to JSON with consistent key ordering.
|
||||
* 2. If a `secret` is configured, generates an HMAC signature.
|
||||
* 3. Uses `@suaveplan/retry` exponential backoff to retry transient failures.
|
||||
* 4. POSTs to `config.url` with standard webhook headers.
|
||||
* 5. Tracks each attempt and returns a `DeliveryResult`.
|
||||
*
|
||||
* 2xx responses are treated as success. 4xx (except 429) are terminal failures.
|
||||
* 429 and 5xx responses are retried up to `config.maxRetries` additional times.
|
||||
*
|
||||
* @param config - Webhook endpoint configuration.
|
||||
* @param payload - The webhook payload to deliver.
|
||||
* @param options - Optional injectable fetch and attempt callback.
|
||||
* @returns A `DeliveryResult` with full attempt history.
|
||||
* @throws {WebhookClientError} With code `DELIVERY_FAILED` if all retries are exhausted.
|
||||
*/
|
||||
export async function deliver(
|
||||
config: WebhookConfig,
|
||||
payload: WebhookPayload,
|
||||
options?: DeliveryOptions
|
||||
): Promise<DeliveryResult> {
|
||||
const fetchFn = options?.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
const maxRetries = config.maxRetries ?? 3;
|
||||
const retryDelay = config.retryDelay ?? 1000;
|
||||
const overallStart = Date.now();
|
||||
|
||||
const body = serializePayload(payload);
|
||||
|
||||
// Pre-generate signature once (it's deterministic)
|
||||
let signature: string | null = null;
|
||||
if (config.secret) {
|
||||
signature = await generateSignature(
|
||||
body,
|
||||
config.secret,
|
||||
config.algorithm ?? "sha256"
|
||||
);
|
||||
}
|
||||
|
||||
const attempts: DeliveryAttempt[] = [];
|
||||
let finalStatusCode: number | undefined;
|
||||
let finalStatus: DeliveryStatus = "pending";
|
||||
|
||||
const delayFn = createExponentialBackoff({
|
||||
initial: retryDelay,
|
||||
base: 2,
|
||||
max: 30000,
|
||||
jitter: "full",
|
||||
});
|
||||
|
||||
try {
|
||||
await retry(
|
||||
async () => {
|
||||
const attemptNumber = attempts.length + 1;
|
||||
finalStatus = attemptNumber > 1 ? "retrying" : "pending";
|
||||
|
||||
const result = await executeAttempt(
|
||||
config,
|
||||
body,
|
||||
signature,
|
||||
attemptNumber,
|
||||
fetchFn,
|
||||
payload
|
||||
);
|
||||
|
||||
attempts.push(result.attempt);
|
||||
options?.onAttempt?.(result.attempt);
|
||||
|
||||
if (result.attempt.statusCode !== undefined) {
|
||||
finalStatusCode = result.attempt.statusCode;
|
||||
}
|
||||
|
||||
if (result.attempt.success) {
|
||||
finalStatus = "delivered";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Non-retryable client error
|
||||
if (
|
||||
result.attempt.statusCode !== undefined &&
|
||||
!isRetryableStatus(result.attempt.statusCode)
|
||||
) {
|
||||
finalStatus = "failed";
|
||||
const terminalErr = new WebhookClientError(
|
||||
WEBHOOK_CLIENT_CODES["DELIVERY_FAILED"],
|
||||
`Webhook delivery failed: HTTP ${result.attempt.statusCode}`
|
||||
) as TerminalError;
|
||||
terminalErr._terminal = true;
|
||||
throw terminalErr;
|
||||
}
|
||||
|
||||
// Retryable failure — throw to trigger retry loop.
|
||||
// error is always defined for non-success attempts (set by executeAttempt).
|
||||
throw new Error(result.attempt.error);
|
||||
},
|
||||
{
|
||||
maxAttempts: maxRetries,
|
||||
delay: delayFn,
|
||||
shouldRetry: (err) => {
|
||||
// Stop retrying on terminal (non-retryable) errors
|
||||
const maybeTerminal = err as Partial<TerminalError>;
|
||||
if (maybeTerminal._terminal === true) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
finalStatus = "failed";
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - overallStart;
|
||||
const { counter, histogram } = getMetrics();
|
||||
counter.add(1, { event: payload.event, status: finalStatus });
|
||||
histogram.record(totalDuration, {
|
||||
event: payload.event,
|
||||
status: finalStatus,
|
||||
});
|
||||
|
||||
if (finalStatus === "failed") {
|
||||
throw new WebhookClientError(
|
||||
WEBHOOK_CLIENT_CODES["DELIVERY_FAILED"],
|
||||
`Webhook delivery failed after ${attempts.length} attempt(s)`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.id,
|
||||
status: finalStatus,
|
||||
attempts,
|
||||
payload,
|
||||
// finalStatusCode is always defined when status is 'delivered' (a 2xx HTTP
|
||||
// response was received). The cast is safe here.
|
||||
finalStatusCode: finalStatusCode as number,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createWebhookClient, WebhookClient } from "./client";
|
||||
export { type DeliveryOptions, deliver } from "./delivery";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
WEBHOOK_CLIENT_CODES,
|
||||
type WebhookClientCode,
|
||||
WebhookClientError,
|
||||
} from "./webhook-client-error";
|
||||
@@ -0,0 +1,92 @@
|
||||
# webhook-client-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the stable error code registry and typed error class for all `@suaveplan/webhook-client` failures. By extending `SuaveplanError` with a known set of codes, consumers can discriminate webhook errors from other `SuaveplanError` instances using both `instanceof` checks and the stable `code` string without coupling themselves to error message text.
|
||||
|
||||
## Features
|
||||
|
||||
- `WEBHOOK_CLIENT_CODES` — stable code registry (`defineCodes`) covering delivery, signature, config, and timeout failure modes
|
||||
- `WebhookClientError` — typed error class that carries a stable `code`, optional `cause`, and integrates with the project error chain traversal utilities
|
||||
- `WebhookClientCode` — union type of all registered code values for exhaustive switch statements
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { WEBHOOK_CLIENT_CODES, WebhookClientError } from '@suaveplan/webhook-client/errors';
|
||||
|
||||
throw new WebhookClientError(
|
||||
'Delivery failed after 3 retries',
|
||||
WEBHOOK_CLIENT_CODES.DELIVERY_FAILED
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { WEBHOOK_CLIENT_CODES, WebhookClientError } from '@suaveplan/webhook-client/errors';
|
||||
import { isSuaveplanError } from '@suaveplan/error';
|
||||
|
||||
async function deliver(url: string): Promise<void> {
|
||||
try {
|
||||
await fetch(url);
|
||||
} catch (err) {
|
||||
throw new WebhookClientError(
|
||||
'Delivery failed',
|
||||
WEBHOOK_CLIENT_CODES.DELIVERY_FAILED,
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Discriminate at the call site
|
||||
try {
|
||||
await deliver('https://example.com/webhook');
|
||||
} catch (err) {
|
||||
if (isSuaveplanError(err) && err.code === WEBHOOK_CLIENT_CODES.DELIVERY_FAILED) {
|
||||
console.error('Webhook failed:', err.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `WEBHOOK_CLIENT_CODES`
|
||||
|
||||
```ts
|
||||
const WEBHOOK_CLIENT_CODES: {
|
||||
readonly DELIVERY_FAILED: string;
|
||||
readonly SIGNATURE_FAILED: string;
|
||||
readonly INVALID_CONFIG: string;
|
||||
readonly TIMEOUT: string;
|
||||
}
|
||||
```
|
||||
|
||||
Stable string codes registered via `defineCodes('WEBHOOK_CLIENT', ...)`. Each key maps to a namespaced code like `"WEBHOOK_CLIENT.DELIVERY_FAILED"`.
|
||||
|
||||
### `WebhookClientCode`
|
||||
|
||||
```ts
|
||||
type WebhookClientCode = (typeof WEBHOOK_CLIENT_CODES)[keyof typeof WEBHOOK_CLIENT_CODES];
|
||||
```
|
||||
|
||||
Union type of all registered code values.
|
||||
|
||||
### `WebhookClientError`
|
||||
|
||||
```ts
|
||||
class WebhookClientError extends SuaveplanError {
|
||||
constructor(message: string, code: string, options?: { cause?: Error }): void;
|
||||
}
|
||||
```
|
||||
|
||||
- `message` — human-readable error description
|
||||
- `code` — one of `WEBHOOK_CLIENT_CODES` values
|
||||
- `options.cause` — optional upstream error for cause-chain traversal
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor signature uses `string` rather than the narrower `WebhookClientCode` union to avoid forcing callers to import the union type just for type-checking purposes. The canonical usage pattern is to always pass a value from `WEBHOOK_CLIENT_CODES`. The `name` is explicitly set to `"WebhookClientError"` so that serialized stack traces are immediately identifiable without class introspection.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
WEBHOOK_CLIENT_CODES,
|
||||
WebhookClientError,
|
||||
} from "./webhook-client-error.js";
|
||||
|
||||
describe("WebhookClientError", () => {
|
||||
it("is instance of Error and WebhookClientError", { timeout: 5000 }, () => {
|
||||
const err = new WebhookClientError(
|
||||
WEBHOOK_CLIENT_CODES["DELIVERY_FAILED"],
|
||||
"test error"
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(WebhookClientError);
|
||||
});
|
||||
|
||||
it("has correct name", { timeout: 5000 }, () => {
|
||||
const err = new WebhookClientError(
|
||||
WEBHOOK_CLIENT_CODES["DELIVERY_FAILED"],
|
||||
"msg"
|
||||
);
|
||||
expect(err.name).toBe("WebhookClientError");
|
||||
});
|
||||
|
||||
it("carries cause when provided", { timeout: 5000 }, () => {
|
||||
const cause = new Error("underlying");
|
||||
const err = new WebhookClientError(
|
||||
WEBHOOK_CLIENT_CODES["DELIVERY_FAILED"],
|
||||
"outer",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("WEBHOOK_CLIENT_CODES contains all expected keys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const keys = Object.keys(WEBHOOK_CLIENT_CODES).sort();
|
||||
expect(keys).toEqual(
|
||||
[
|
||||
"DELIVERY_FAILED",
|
||||
"INVALID_CONFIG",
|
||||
"SIGNATURE_FAILED",
|
||||
"TIMEOUT",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("all codes are non-empty strings", { timeout: 5000 }, () => {
|
||||
for (const val of Object.values(WEBHOOK_CLIENT_CODES)) {
|
||||
expect(typeof val).toBe("string");
|
||||
expect(val.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error code registry for `@suaveplan/webhook-client`.
|
||||
*
|
||||
* All codes are prefixed with `WEBHOOK_CLIENT` for unambiguous identification
|
||||
* across the monorepo's shared error code space.
|
||||
*/
|
||||
export const WEBHOOK_CLIENT_CODES = defineCodes("WEBHOOK_CLIENT", [
|
||||
"DELIVERY_FAILED",
|
||||
"SIGNATURE_FAILED",
|
||||
"INVALID_CONFIG",
|
||||
"TIMEOUT",
|
||||
] as const);
|
||||
|
||||
export type WebhookClientCode =
|
||||
(typeof WEBHOOK_CLIENT_CODES)[keyof typeof WEBHOOK_CLIENT_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for all `@suaveplan/webhook-client` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` so that callers can use `instanceof WebhookClientError`
|
||||
* and also inspect the stable `code` string for programmatic handling.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* throw new WebhookClientError(
|
||||
* WEBHOOK_CLIENT_CODES.DELIVERY_FAILED,
|
||||
* 'Delivery failed after 3 retries',
|
||||
* { cause: originalError }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class WebhookClientError extends SuaveplanError<WebhookClientCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||||
public constructor(
|
||||
code: WebhookClientCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Types
|
||||
|
||||
// Client
|
||||
export {
|
||||
createWebhookClient,
|
||||
WebhookClient,
|
||||
} from "./core/client/webhook-client.js";
|
||||
export type { DeliveryOptions } from "./core/delivery/webhook-delivery.js";
|
||||
// Delivery
|
||||
export { deliver } from "./core/delivery/webhook-delivery.js";
|
||||
export type { WebhookClientCode } from "./errors/webhook-client-error.js";
|
||||
// Errors
|
||||
export {
|
||||
WEBHOOK_CLIENT_CODES,
|
||||
WebhookClientError,
|
||||
} from "./errors/webhook-client-error.js";
|
||||
export type {
|
||||
DeliveryAttempt,
|
||||
DeliveryResult,
|
||||
DeliveryStatus,
|
||||
WebhookConfig,
|
||||
WebhookPayload,
|
||||
WebhookSignatureAlgorithm,
|
||||
} from "./types/webhook-client.types.js";
|
||||
// Utils
|
||||
export { buildPayload, serializePayload } from "./utils/payload-builder.js";
|
||||
export {
|
||||
generateSignature,
|
||||
parseSignatureHeader,
|
||||
verifySignature,
|
||||
} from "./utils/signature.js";
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
DeliveryAttempt,
|
||||
DeliveryResult,
|
||||
DeliveryStatus,
|
||||
WebhookConfig,
|
||||
WebhookPayload,
|
||||
WebhookSignatureAlgorithm,
|
||||
} from "./webhook-client.types";
|
||||
@@ -0,0 +1,164 @@
|
||||
# webhook-client.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the TypeScript type vocabulary for the `@suaveplan/webhook-client` package. All public interfaces, union types, and type aliases are gathered here so that every other module in the package imports from a single canonical location, preventing circular dependencies and keeping type definitions easy to discover and evolve.
|
||||
|
||||
These types are also re-exported from the package root so consumer code can import them directly from `@suaveplan/webhook-client` without reaching into internal paths.
|
||||
|
||||
## Features
|
||||
|
||||
- `WebhookSignatureAlgorithm` — union of supported HMAC hash algorithms (`"sha256"` | `"sha512"`)
|
||||
- `DeliveryStatus` — lifecycle discriminant covering the four states a delivery can be in
|
||||
- `WebhookConfig` — full endpoint configuration including URL, secret, algorithm, headers, timeout, and retry settings
|
||||
- `WebhookPayload` — canonical envelope sent to subscriber endpoints with stable key order
|
||||
- `DeliveryAttempt` — per-attempt record capturing HTTP status, duration, success flag, and optional error message
|
||||
- `DeliveryResult` — aggregated outcome of an entire delivery run with all attempt history and final status code
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
WebhookConfig,
|
||||
WebhookPayload,
|
||||
DeliveryResult,
|
||||
} from "@suaveplan/webhook-client";
|
||||
|
||||
const config: WebhookConfig = {
|
||||
url: "https://partner.example.com/hooks",
|
||||
secret: "shared-secret",
|
||||
algorithm: "sha256",
|
||||
maxRetries: 3,
|
||||
retryDelay: 500,
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
const payload: WebhookPayload = {
|
||||
id: "d290f1ee-6c54-4b01-90e6-d701748f0851",
|
||||
event: "order.created",
|
||||
data: { orderId: 42, total: 99.99 },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
DeliveryAttempt,
|
||||
DeliveryResult,
|
||||
DeliveryStatus,
|
||||
} from "@suaveplan/webhook-client";
|
||||
|
||||
function summarise(result: DeliveryResult): string {
|
||||
const status: DeliveryStatus = result.status;
|
||||
const attempts: DeliveryAttempt[] = result.attempts;
|
||||
|
||||
const lines = attempts.map(
|
||||
(a) =>
|
||||
` attempt ${a.attemptNumber}: ${a.success ? "ok" : `fail(${a.statusCode ?? "no-response"})`} in ${a.duration}ms`
|
||||
);
|
||||
|
||||
return [`delivery ${result.id} — ${status}`, ...lines].join("\n");
|
||||
}
|
||||
|
||||
// Narrow on DeliveryStatus to gate downstream processing
|
||||
function isTerminal(status: DeliveryStatus): boolean {
|
||||
return status === "delivered" || status === "failed";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `WebhookSignatureAlgorithm`
|
||||
|
||||
```typescript
|
||||
type WebhookSignatureAlgorithm = "sha256" | "sha512";
|
||||
```
|
||||
|
||||
Union of HMAC algorithms supported by `generateSignature` and `verifySignature`. Drives which Web Crypto hash (`SHA-256` or `SHA-512`) is used and which HTTP header name (`X-Hub-Signature-256` or `X-Hub-Signature-512`) is set on delivery.
|
||||
|
||||
---
|
||||
|
||||
### `DeliveryStatus`
|
||||
|
||||
```typescript
|
||||
type DeliveryStatus = "pending" | "delivered" | "failed" | "retrying";
|
||||
```
|
||||
|
||||
Lifecycle state of a webhook delivery operation.
|
||||
|
||||
| Value | Description |
|
||||
|---|---|
|
||||
| `"pending"` | First attempt in progress — not yet resolved |
|
||||
| `"retrying"` | A prior attempt failed; a subsequent attempt is in progress |
|
||||
| `"delivered"` | A 2xx response was received; delivery is complete |
|
||||
| `"failed"` | All attempts exhausted or a non-retryable status was received |
|
||||
|
||||
---
|
||||
|
||||
### `WebhookConfig`
|
||||
|
||||
Full configuration object for a webhook endpoint.
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `url` | `string` | Yes | — | Target URL to POST webhook payloads to |
|
||||
| `secret` | `string` | No | — | Shared HMAC secret; omit to skip signing |
|
||||
| `algorithm` | `WebhookSignatureAlgorithm` | No | `"sha256"` | Hash algorithm for HMAC |
|
||||
| `headers` | `Record<string, string>` | No | `{}` | Additional HTTP headers on every delivery |
|
||||
| `timeout` | `number` | No | `30000` | Per-request timeout in milliseconds |
|
||||
| `maxRetries` | `number` | No | `3` | Total delivery attempts (including first) |
|
||||
| `retryDelay` | `number` | No | `1000` | Base retry delay (ms) for exponential backoff |
|
||||
|
||||
---
|
||||
|
||||
### `WebhookPayload`
|
||||
|
||||
The canonical envelope sent to a subscriber endpoint.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | Unique delivery ID (UUID v4 or timestamp-based fallback) |
|
||||
| `event` | `string` | Event type discriminator (e.g. `"order.created"`) |
|
||||
| `data` | `unknown` | Arbitrary event data |
|
||||
| `timestamp` | `string` | ISO 8601 UTC creation time |
|
||||
|
||||
---
|
||||
|
||||
### `DeliveryAttempt`
|
||||
|
||||
Record of a single HTTP POST attempt.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `attemptNumber` | `number` | 1-based attempt index |
|
||||
| `timestamp` | `string` | ISO 8601 time the attempt started |
|
||||
| `statusCode` | `number?` | HTTP status if a response was received |
|
||||
| `success` | `boolean` | Whether the response was 2xx |
|
||||
| `error` | `string?` | Human-readable error message on failure |
|
||||
| `duration` | `number` | Round-trip time in milliseconds |
|
||||
|
||||
---
|
||||
|
||||
### `DeliveryResult`
|
||||
|
||||
Aggregated outcome of an entire webhook delivery run.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | Delivery ID matching `WebhookPayload.id` |
|
||||
| `status` | `DeliveryStatus` | Final status after all attempts |
|
||||
| `attempts` | `DeliveryAttempt[]` | Ordered attempt history |
|
||||
| `payload` | `WebhookPayload` | The original payload delivered |
|
||||
| `finalStatusCode` | `number?` | Final HTTP status code if a response was received |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All types are interface or type-alias declarations only — no runtime code is emitted. This makes the module fully tree-shakable and zero-cost to import in both Node.js and browser environments.
|
||||
|
||||
The `data` field on `WebhookPayload` is typed as `unknown` rather than `Record<string, unknown>` to accommodate primitive payloads (numbers, strings, arrays) without forcing callers to cast. Downstream consumers who need a narrower type should use a Zod schema or type guard at the boundary.
|
||||
|
||||
`DeliveryAttempt.statusCode` and `DeliveryAttempt.error` are optional to handle the case where `fetch` itself throws before an HTTP response is received (e.g. DNS failure, timeout, network error). In those situations `statusCode` is absent and `error` carries the exception message.
|
||||
|
||||
`finalStatusCode` on `DeliveryResult` mirrors `DeliveryAttempt.statusCode` of the last attempt, provided for ergonomic access without array traversal.
|
||||
@@ -0,0 +1,73 @@
|
||||
/** Supported HMAC algorithms for webhook signature generation and verification. */
|
||||
export type WebhookSignatureAlgorithm = "sha256" | "sha512";
|
||||
|
||||
/** Lifecycle state of a webhook delivery attempt. */
|
||||
export type DeliveryStatus = "pending" | "delivered" | "failed" | "retrying";
|
||||
|
||||
/**
|
||||
* Configuration for a webhook endpoint.
|
||||
*/
|
||||
export interface WebhookConfig {
|
||||
/** Target URL to POST webhook payloads to. */
|
||||
url: string;
|
||||
/** Shared secret for HMAC signature generation. If omitted, no signature is sent. */
|
||||
secret?: string;
|
||||
/** HMAC algorithm used to sign the payload. Defaults to `'sha256'`. */
|
||||
algorithm?: WebhookSignatureAlgorithm;
|
||||
/** Additional HTTP headers to send with every delivery. */
|
||||
headers?: Record<string, string>;
|
||||
/** Per-request timeout in milliseconds. Defaults to `30000`. */
|
||||
timeout?: number;
|
||||
/** Maximum number of delivery attempts (including the first). Defaults to `3`. */
|
||||
maxRetries?: number;
|
||||
/** Base retry delay in milliseconds for exponential backoff. Defaults to `1000`. */
|
||||
retryDelay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical webhook payload sent to a subscriber endpoint.
|
||||
*/
|
||||
export interface WebhookPayload {
|
||||
/** Unique delivery ID (UUID v4 or crypto.randomUUID fallback). */
|
||||
id: string;
|
||||
/** Event type discriminator (e.g. `"order.created"`). */
|
||||
event: string;
|
||||
/** Arbitrary event data. */
|
||||
data: unknown;
|
||||
/** ISO 8601 timestamp of when the payload was created. */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single delivery attempt record.
|
||||
*/
|
||||
export interface DeliveryAttempt {
|
||||
/** 1-based attempt number. */
|
||||
attemptNumber: number;
|
||||
/** ISO 8601 timestamp of when the attempt was made. */
|
||||
timestamp: string;
|
||||
/** HTTP status code returned by the endpoint, if a response was received. */
|
||||
statusCode?: number;
|
||||
/** Whether this attempt was considered successful. */
|
||||
success: boolean;
|
||||
/** Human-readable error message if the attempt failed. */
|
||||
error?: string;
|
||||
/** Round-trip duration of this attempt in milliseconds. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The aggregated result of a webhook delivery, including all retry attempts.
|
||||
*/
|
||||
export interface DeliveryResult {
|
||||
/** Delivery ID matching `WebhookPayload.id`. */
|
||||
id: string;
|
||||
/** Final delivery status after all attempts. */
|
||||
status: DeliveryStatus;
|
||||
/** Ordered list of all delivery attempts. */
|
||||
attempts: DeliveryAttempt[];
|
||||
/** The original payload that was delivered. */
|
||||
payload: WebhookPayload;
|
||||
/** The final HTTP status code, if any response was received. */
|
||||
finalStatusCode?: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { buildPayload, generateId, serializePayload } from "./payload-builder";
|
||||
export {
|
||||
generateSignature,
|
||||
parseSignatureHeader,
|
||||
verifySignature,
|
||||
} from "./signature";
|
||||
@@ -0,0 +1,75 @@
|
||||
# payload-builder
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this module are deliberately simple but important for correctness: `buildPayload` guarantees every payload gets a unique ID and an accurate timestamp, while `serializePayload` enforces consistent key ordering so that HMAC signatures computed independently on sender and receiver sides always produce identical digests.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildPayload` — creates a complete `WebhookPayload` with auto-generated UUID (via `crypto.randomUUID()` with a `Date.now()` fallback) and ISO 8601 timestamp
|
||||
- `serializePayload` — deterministic JSON serialization with explicit key ordering: `id`, `event`, `data`, `timestamp`
|
||||
- Zero external dependencies — uses only built-in `JSON.stringify` and the Web Crypto API
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { buildPayload, serializePayload } from '@suaveplan/webhook-client';
|
||||
|
||||
const payload = buildPayload('order.created', { orderId: 42, total: 99.99 });
|
||||
// { id: 'f7e2...', event: 'order.created', data: { orderId: 42, total: 99.99 }, timestamp: '2026-04-19T...' }
|
||||
|
||||
const body = serializePayload(payload);
|
||||
// '{"id":"f7e2...","event":"order.created","data":{"orderId":42,"total":99.99},"timestamp":"..."}'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { buildPayload, serializePayload } from '@suaveplan/webhook-client';
|
||||
import { generateSignature } from '@suaveplan/webhook-client/signature';
|
||||
|
||||
// Inject a known ID for idempotency key correlation
|
||||
const payload = buildPayload(
|
||||
'payment.processed',
|
||||
{ invoiceId: 'inv-001', amount: 250.00 },
|
||||
'explicit-delivery-id-from-idempotency-guard'
|
||||
);
|
||||
|
||||
const body = serializePayload(payload);
|
||||
const signature = await generateSignature(body, process.env.WEBHOOK_SECRET!, 'sha256');
|
||||
|
||||
await fetch('https://partner.example.com/webhooks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Hub-Signature-256': signature,
|
||||
},
|
||||
body,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `buildPayload(event, data, id?)`
|
||||
|
||||
```ts
|
||||
function buildPayload(event: string, data: unknown, id?: string): WebhookPayload
|
||||
```
|
||||
|
||||
- `event` — event type string, e.g. `"order.created"`
|
||||
- `data` — arbitrary payload data (any JSON-serializable value)
|
||||
- `id` — optional explicit delivery ID; defaults to `crypto.randomUUID()` or a timestamp+random fallback
|
||||
- Returns a fully formed `WebhookPayload` with `id`, `event`, `data`, and `timestamp`
|
||||
|
||||
### `serializePayload(payload)`
|
||||
|
||||
```ts
|
||||
function serializePayload(payload: WebhookPayload): string
|
||||
```
|
||||
|
||||
- `payload` — a `WebhookPayload` object
|
||||
- Returns a JSON string with keys in the order: `id`, `event`, `data`, `timestamp`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Key ordering in `serializePayload` is enforced by constructing an intermediate object literal with explicit property order before calling `JSON.stringify`. This is more reliable than relying on insertion-order preservation in dynamic objects, especially when payloads are reconstructed from deserialized sources. The `generateId()` fallback uses `Date.now()` hex plus 32-bit random hex, producing IDs that are time-sortable and reasonably unique for delivery tracking in environments where `crypto.randomUUID` is unavailable.
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildPayload,
|
||||
generateId,
|
||||
serializePayload,
|
||||
} from "./payload-builder.js";
|
||||
|
||||
describe("generateId", () => {
|
||||
it("uses provided randomUUID function when given", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mockUuid = () => "fixed-uuid-1234";
|
||||
const id = generateId(mockUuid);
|
||||
expect(id).toBe("fixed-uuid-1234");
|
||||
});
|
||||
|
||||
it("falls back to timestamp+hex when no randomUUID provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const id = generateId(undefined);
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
// Should contain a dash from the fallback format: "<hex>-<hex>"
|
||||
expect(id).toMatch(/^[0-9a-f]+-[0-9a-f]{8}$/);
|
||||
});
|
||||
|
||||
it("fallback produces unique IDs on successive calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = generateId(undefined);
|
||||
const b = generateId(undefined);
|
||||
// They should differ (timestamp or random component will differ)
|
||||
// In practice they may be the same if called within the same ms, so just
|
||||
// check they are valid format
|
||||
expect(a).toMatch(/^[0-9a-f]+-[0-9a-f]{8}$/);
|
||||
expect(b).toMatch(/^[0-9a-f]+-[0-9a-f]{8}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPayload (fallback path)", () => {
|
||||
it("uses timestamp fallback when globalThis.crypto is unavailable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Temporarily remove crypto to exercise the fallback branch in buildPayload.
|
||||
// crypto may be a read-only getter in newer Node — use defineProperty.
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"crypto"
|
||||
);
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
try {
|
||||
const payload = buildPayload("fallback.event", { test: true });
|
||||
expect(typeof payload.id).toBe("string");
|
||||
expect(payload.id.length).toBeGreaterThan(0);
|
||||
// Fallback format: <hex>-<hex8>
|
||||
expect(payload.id).toMatch(/^[0-9a-f]+-[0-9a-f]{8}$/);
|
||||
} finally {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, "crypto", descriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPayload", () => {
|
||||
it("has required fields: id, event, data, timestamp", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const payload = buildPayload("order.created", { orderId: 1 });
|
||||
expect(typeof payload.id).toBe("string");
|
||||
expect(payload.id.length).toBeGreaterThan(0);
|
||||
expect(payload.event).toBe("order.created");
|
||||
expect(payload.data).toEqual({ orderId: 1 });
|
||||
expect(typeof payload.timestamp).toBe("string");
|
||||
expect(() => new Date(payload.timestamp)).not.toThrow();
|
||||
});
|
||||
|
||||
it("timestamp is valid ISO 8601", { timeout: 5000 }, () => {
|
||||
const payload = buildPayload("test.event", {});
|
||||
const d = new Date(payload.timestamp);
|
||||
expect(Number.isNaN(d.getTime())).toBe(false);
|
||||
});
|
||||
|
||||
it("generates unique IDs for successive calls", { timeout: 5000 }, () => {
|
||||
const a = buildPayload("event.a", {});
|
||||
const b = buildPayload("event.b", {});
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
|
||||
it("uses provided id when supplied", { timeout: 5000 }, () => {
|
||||
const payload = buildPayload("test", {}, "my-custom-id");
|
||||
expect(payload.id).toBe("my-custom-id");
|
||||
});
|
||||
|
||||
it("accepts null and primitive data", { timeout: 5000 }, () => {
|
||||
const p1 = buildPayload("test", null);
|
||||
expect(p1.data).toBeNull();
|
||||
const p2 = buildPayload("test", 42);
|
||||
expect(p2.data).toBe(42);
|
||||
const p3 = buildPayload("test", "string value");
|
||||
expect(p3.data).toBe("string value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializePayload", () => {
|
||||
it("produces valid JSON", { timeout: 5000 }, () => {
|
||||
const payload = buildPayload("test", { key: "value" });
|
||||
const json = serializePayload(payload);
|
||||
expect(() => JSON.parse(json)).not.toThrow();
|
||||
});
|
||||
|
||||
it("round-trips all fields correctly", { timeout: 5000 }, () => {
|
||||
const payload = buildPayload("order.created", { orderId: 99 });
|
||||
const json = serializePayload(payload);
|
||||
const parsed = JSON.parse(json) as typeof payload;
|
||||
expect(parsed.id).toBe(payload.id);
|
||||
expect(parsed.event).toBe(payload.event);
|
||||
expect(parsed.data).toEqual(payload.data);
|
||||
expect(parsed.timestamp).toBe(payload.timestamp);
|
||||
});
|
||||
|
||||
it("has consistent key order: id, event, data, timestamp", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const payload = buildPayload("evt", { x: 1 });
|
||||
const json = serializePayload(payload);
|
||||
const keys = Object.keys(JSON.parse(json));
|
||||
expect(keys).toEqual(["id", "event", "data", "timestamp"]);
|
||||
});
|
||||
|
||||
it("produces deterministic output for same payload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const payload = buildPayload("evt", { a: 1 }, "fixed-id");
|
||||
// Override timestamp to ensure determinism
|
||||
const fixedPayload = {
|
||||
...payload,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
const a = serializePayload(fixedPayload);
|
||||
const b = serializePayload(fixedPayload);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { WebhookPayload } from "../types/webhook-client.types.js";
|
||||
|
||||
/**
|
||||
* Generates a UUID-like delivery ID using the provided randomUUID function,
|
||||
* or a timestamp-based fallback if no function is provided.
|
||||
*
|
||||
* @param randomUUID - Optional UUID generator function.
|
||||
* @returns A unique ID string.
|
||||
*/
|
||||
export function generateId(randomUUID?: () => string): string {
|
||||
if (randomUUID !== undefined) {
|
||||
return randomUUID();
|
||||
}
|
||||
// Fallback: timestamp + random hex
|
||||
const ts = Date.now().toString(16);
|
||||
const rand = Math.floor(Math.random() * 0xffffffff)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
return `${ts}-${rand}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a `WebhookPayload` for the given event and data.
|
||||
*
|
||||
* Assigns a unique delivery ID (via `crypto.randomUUID()` or fallback) and
|
||||
* stamps the payload with the current UTC time as ISO 8601.
|
||||
*
|
||||
* @param event - Event type discriminator, e.g. `"order.created"`.
|
||||
* @param data - Arbitrary event payload data.
|
||||
* @param id - Optional explicit delivery ID (defaults to a generated UUID).
|
||||
* @returns A fully formed `WebhookPayload`.
|
||||
*/
|
||||
export function buildPayload(
|
||||
event: string,
|
||||
data: unknown,
|
||||
id?: string
|
||||
): WebhookPayload {
|
||||
return {
|
||||
id:
|
||||
id ??
|
||||
generateId(
|
||||
typeof globalThis.crypto !== "undefined" &&
|
||||
typeof globalThis.crypto.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID.bind(globalThis.crypto)
|
||||
: undefined
|
||||
),
|
||||
event,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a `WebhookPayload` to a JSON string with consistent key ordering.
|
||||
*
|
||||
* Consistent key ordering is important so that HMAC signatures computed on both
|
||||
* sides of the wire will always match given the same logical payload.
|
||||
*
|
||||
* Key order: `id`, `event`, `data`, `timestamp`.
|
||||
*
|
||||
* @param payload - The payload to serialize.
|
||||
* @returns A JSON string representation.
|
||||
*/
|
||||
export function serializePayload(payload: WebhookPayload): string {
|
||||
// Explicit key ordering for deterministic serialization
|
||||
return JSON.stringify({
|
||||
id: payload.id,
|
||||
event: payload.event,
|
||||
data: payload.data,
|
||||
timestamp: payload.timestamp,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# signature
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.subtle`). The implementation is runtime-agnostic and works in Node.js 18+, Bun, Deno, and modern browsers. It follows the GitHub webhook signature format (`sha256=<hex>`) which is the de-facto standard for HMAC-authenticated webhooks.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateSignature` — produces a `"sha256=<hex>"` or `"sha512=<hex>"` signature using HMAC-SHA256 / HMAC-SHA512
|
||||
- `verifySignature` — constant-time verification via `crypto.subtle.verify` to prevent timing attacks
|
||||
- `parseSignatureHeader` — parses `"<algorithm>=<value>"` signature headers into structured form, returns `null` for invalid input
|
||||
- Zero external dependencies — uses only `globalThis.crypto.subtle`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generateSignature, verifySignature } from '@suaveplan/webhook-client/signature';
|
||||
|
||||
const body = JSON.stringify({ event: 'order.created', data: { id: 1 } });
|
||||
const secret = 'my-secret';
|
||||
|
||||
const sig = await generateSignature(body, secret, 'sha256');
|
||||
// => "sha256=a3f4b2c1..."
|
||||
|
||||
const valid = await verifySignature(body, sig, secret, 'sha256');
|
||||
// => true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
generateSignature,
|
||||
verifySignature,
|
||||
parseSignatureHeader,
|
||||
} from '@suaveplan/webhook-client/signature';
|
||||
|
||||
// Incoming request from a webhook provider
|
||||
const incomingBody = await request.text();
|
||||
const sigHeader = request.headers.get('X-Hub-Signature-256') ?? '';
|
||||
|
||||
const parsed = parseSignatureHeader(sigHeader);
|
||||
if (!parsed) {
|
||||
throw new Error('Missing or malformed signature header');
|
||||
}
|
||||
|
||||
const isValid = await verifySignature(
|
||||
incomingBody,
|
||||
sigHeader,
|
||||
process.env.WEBHOOK_SECRET!,
|
||||
'sha256'
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generateSignature(payload, secret, algorithm)`
|
||||
|
||||
```ts
|
||||
async function generateSignature(
|
||||
payload: string,
|
||||
secret: string,
|
||||
algorithm: WebhookSignatureAlgorithm
|
||||
): Promise<string>
|
||||
```
|
||||
|
||||
- `payload` — the raw serialized payload string to sign
|
||||
- `secret` — the shared HMAC secret
|
||||
- `algorithm` — `'sha256'` or `'sha512'`
|
||||
- Returns `"sha256=<hex>"` or `"sha512=<hex>"`
|
||||
- Throws `WebhookClientError` (code `SIGNATURE_FAILED`) if the Web Crypto API fails
|
||||
|
||||
### `verifySignature(payload, signature, secret, algorithm)`
|
||||
|
||||
```ts
|
||||
async function verifySignature(
|
||||
payload: string,
|
||||
signature: string,
|
||||
secret: string,
|
||||
algorithm: WebhookSignatureAlgorithm
|
||||
): Promise<boolean>
|
||||
```
|
||||
|
||||
- `payload` — the raw payload string
|
||||
- `signature` — the full signature header value (e.g. `"sha256=abc..."`)
|
||||
- `secret` — shared secret
|
||||
- `algorithm` — expected algorithm; returns `false` if algorithm in header differs
|
||||
- Returns `true` if valid, `false` otherwise (never throws on invalid input)
|
||||
|
||||
### `parseSignatureHeader(header)`
|
||||
|
||||
```ts
|
||||
function parseSignatureHeader(
|
||||
header: string
|
||||
): { algorithm: string; value: string } | null
|
||||
```
|
||||
|
||||
- `header` — raw header value like `"sha256=abc123"`
|
||||
- Returns `{ algorithm: 'sha256', value: 'abc123' }` or `null` if malformed
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The verification path uses `crypto.subtle.verify` rather than a manual HMAC + constant-time string compare to get OS-level timing attack resistance. The `parseSignatureHeader` function is purely synchronous and safe to call in request validation middleware before any async work. All functions gracefully return `false` or `null` on invalid input rather than throwing, except `generateSignature` which throws since a generation failure is always an unexpected error worth surfacing.
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
generateSignature,
|
||||
parseSignatureHeader,
|
||||
verifySignature,
|
||||
} from "./signature.js";
|
||||
|
||||
/**
|
||||
* Swap `globalThis.crypto.subtle` to a stub, returning a restore fn.
|
||||
* Uses defineProperty since `crypto` may be a read-only getter in newer Node.
|
||||
*/
|
||||
function swapSubtle(subtle: SubtleCrypto): () => void {
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"crypto"
|
||||
);
|
||||
const originalCrypto = globalThis.crypto;
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { ...originalCrypto, subtle },
|
||||
});
|
||||
return () => {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(globalThis, "crypto", originalDescriptor);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("generateSignature", () => {
|
||||
it("produces sha256=... format", { timeout: 30000 }, async () => {
|
||||
const sig = await generateSignature("hello", "secret", "sha256");
|
||||
expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("produces sha512=... format", { timeout: 30000 }, async () => {
|
||||
const sig = await generateSignature("hello", "secret", "sha512");
|
||||
expect(sig).toMatch(/^sha512=[0-9a-f]{128}$/);
|
||||
});
|
||||
|
||||
it("throws WebhookClientError when crypto.subtle.importKey throws an Error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const original = globalThis.crypto.subtle;
|
||||
const throwing = Object.create(original) as typeof original;
|
||||
throwing.importKey = () => {
|
||||
throw new Error("importKey failure");
|
||||
};
|
||||
const restore = swapSubtle(throwing);
|
||||
try {
|
||||
const { WebhookClientError: WCE } = await import(
|
||||
"../errors/webhook-client-error.js"
|
||||
);
|
||||
await expect(
|
||||
generateSignature("hello", "secret", "sha256")
|
||||
).rejects.toBeInstanceOf(WCE);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws WebhookClientError when crypto throws a non-Error (string)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const original = globalThis.crypto.subtle;
|
||||
const throwing = Object.create(original) as typeof original;
|
||||
(throwing as unknown as Record<string, unknown>).importKey = () => {
|
||||
// Throw a plain object (non-Error) to exercise the String(err) catch path
|
||||
throw { code: "non-error-object" };
|
||||
};
|
||||
const restore = swapSubtle(throwing);
|
||||
try {
|
||||
const { WebhookClientError: WCE } = await import(
|
||||
"../errors/webhook-client-error.js"
|
||||
);
|
||||
await expect(
|
||||
generateSignature("hello", "secret", "sha256")
|
||||
).rejects.toBeInstanceOf(WCE);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("produces deterministic output for same input", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const a = await generateSignature("payload", "mysecret", "sha256");
|
||||
const b = await generateSignature("payload", "mysecret", "sha256");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("produces different output for different payloads", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const a = await generateSignature("payload1", "secret", "sha256");
|
||||
const b = await generateSignature("payload2", "secret", "sha256");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("produces different output for different secrets", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const a = await generateSignature("payload", "secret1", "sha256");
|
||||
const b = await generateSignature("payload", "secret2", "sha256");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifySignature", () => {
|
||||
it("returns true for valid signature", { timeout: 30000 }, async () => {
|
||||
const payload = JSON.stringify({ event: "test", data: {} });
|
||||
const sig = await generateSignature(payload, "secret", "sha256");
|
||||
const result = await verifySignature(payload, sig, "secret", "sha256");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for tampered payload", { timeout: 30000 }, async () => {
|
||||
const payload = JSON.stringify({ event: "test", data: {} });
|
||||
const sig = await generateSignature(payload, "secret", "sha256");
|
||||
const result = await verifySignature(
|
||||
`${payload} tampered`,
|
||||
sig,
|
||||
"secret",
|
||||
"sha256"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for wrong secret", { timeout: 30000 }, async () => {
|
||||
const payload = "payload";
|
||||
const sig = await generateSignature(payload, "secret", "sha256");
|
||||
const result = await verifySignature(
|
||||
payload,
|
||||
sig,
|
||||
"wrong-secret",
|
||||
"sha256"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when algorithm mismatch", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const payload = "payload";
|
||||
const sig = await generateSignature(payload, "secret", "sha256");
|
||||
// sig is "sha256=..." but we pass sha512 algorithm
|
||||
const result = await verifySignature(payload, sig, "secret", "sha512");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty signature", { timeout: 30000 }, async () => {
|
||||
const result = await verifySignature("payload", "", "secret", "sha256");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for malformed signature", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const result = await verifySignature(
|
||||
"payload",
|
||||
"notasig",
|
||||
"secret",
|
||||
"sha256"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("sha512 round-trip", { timeout: 30000 }, async () => {
|
||||
const payload = "some data";
|
||||
const sig = await generateSignature(payload, "s3cr3t", "sha512");
|
||||
const result = await verifySignature(payload, sig, "s3cr3t", "sha512");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when crypto.subtle.verify throws internally", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Generate a well-formed real signature first (before swapping subtle)
|
||||
const realSig = await generateSignature("payload", "secret", "sha256");
|
||||
|
||||
// Now swap subtle to a throwing stub for the verify call
|
||||
const original = globalThis.crypto.subtle;
|
||||
const throwing = Object.create(original) as typeof original;
|
||||
throwing.verify = () => {
|
||||
throw new Error("crypto failure");
|
||||
};
|
||||
const restore = swapSubtle(throwing);
|
||||
try {
|
||||
const result = await verifySignature(
|
||||
"payload",
|
||||
realSig,
|
||||
"secret",
|
||||
"sha256"
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSignatureHeader", () => {
|
||||
it("parses sha256=abc123 correctly", { timeout: 5000 }, () => {
|
||||
const result = parseSignatureHeader("sha256=abc123");
|
||||
expect(result).toEqual({ algorithm: "sha256", value: "abc123" });
|
||||
});
|
||||
|
||||
it("parses sha512=hexvalue correctly", { timeout: 5000 }, () => {
|
||||
const result = parseSignatureHeader("sha512=deadbeef");
|
||||
expect(result).toEqual({ algorithm: "sha512", value: "deadbeef" });
|
||||
});
|
||||
|
||||
it("returns null for empty string", { timeout: 5000 }, () => {
|
||||
expect(parseSignatureHeader("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing = separator", { timeout: 5000 }, () => {
|
||||
expect(parseSignatureHeader("sha256abc123")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for leading = (no algorithm)", { timeout: 5000 }, () => {
|
||||
expect(parseSignatureHeader("=abc123")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for trailing = (no value)", { timeout: 5000 }, () => {
|
||||
expect(parseSignatureHeader("sha256=")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles multiple = signs by taking only first", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parseSignatureHeader("sha256=abc=def");
|
||||
expect(result).toEqual({ algorithm: "sha256", value: "abc=def" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
WEBHOOK_CLIENT_CODES,
|
||||
WebhookClientError,
|
||||
} from "../errors/webhook-client-error.js";
|
||||
import type { WebhookSignatureAlgorithm } from "../types/webhook-client.types.js";
|
||||
|
||||
const ALG_MAP: Record<WebhookSignatureAlgorithm, string> = {
|
||||
sha256: "SHA-256",
|
||||
sha512: "SHA-512",
|
||||
};
|
||||
|
||||
/**
|
||||
* Imports a HMAC key from the raw secret string using the Web Crypto API.
|
||||
*/
|
||||
async function importHmacKey(
|
||||
secret: string,
|
||||
algorithm: WebhookSignatureAlgorithm
|
||||
): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
return globalThis.crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(secret),
|
||||
{ name: "HMAC", hash: ALG_MAP[algorithm] },
|
||||
false,
|
||||
["sign", "verify"]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ArrayBuffer to a lowercase hex string.
|
||||
*/
|
||||
function bufferToHex(buf: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an HMAC signature for a webhook payload string.
|
||||
*
|
||||
* Returns a signature in the form `"sha256=<hex>"` or `"sha512=<hex>"`,
|
||||
* matching the GitHub webhook signature format widely used in the industry.
|
||||
*
|
||||
* @param payload - The serialized payload string to sign.
|
||||
* @param secret - The shared HMAC secret.
|
||||
* @param algorithm - The hash algorithm to use.
|
||||
* @returns The signature string, e.g. `"sha256=abc123..."`.
|
||||
* @throws {WebhookClientError} With code `SIGNATURE_FAILED` if crypto fails.
|
||||
*/
|
||||
export async function generateSignature(
|
||||
payload: string,
|
||||
secret: string,
|
||||
algorithm: WebhookSignatureAlgorithm
|
||||
): Promise<string> {
|
||||
try {
|
||||
const key = await importHmacKey(secret, algorithm);
|
||||
const enc = new TextEncoder();
|
||||
const sig = await globalThis.crypto.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
enc.encode(payload)
|
||||
);
|
||||
return `${algorithm}=${bufferToHex(sig)}`;
|
||||
} catch (err) {
|
||||
const cause = err instanceof Error ? err : new Error(String(err));
|
||||
throw new WebhookClientError(
|
||||
WEBHOOK_CLIENT_CODES["SIGNATURE_FAILED"],
|
||||
"HMAC signature generation failed",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an HMAC signature against a payload string using constant-time comparison.
|
||||
*
|
||||
* Parses the `signature` header (e.g. `"sha256=abc..."`) and uses the Web Crypto
|
||||
* API's built-in constant-time `verify` to prevent timing attacks.
|
||||
*
|
||||
* @param payload - The raw serialized payload string.
|
||||
* @param signature - The signature header value (e.g. `"sha256=abc..."`).
|
||||
* @param secret - The shared HMAC secret.
|
||||
* @param algorithm - The expected algorithm.
|
||||
* @returns `true` if the signature is valid, `false` otherwise.
|
||||
*/
|
||||
export async function verifySignature(
|
||||
payload: string,
|
||||
signature: string,
|
||||
secret: string,
|
||||
algorithm: WebhookSignatureAlgorithm
|
||||
): Promise<boolean> {
|
||||
const parsed = parseSignatureHeader(signature);
|
||||
if (!parsed || parsed.algorithm !== algorithm) return false;
|
||||
|
||||
try {
|
||||
const key = await importHmacKey(secret, algorithm);
|
||||
const enc = new TextEncoder();
|
||||
|
||||
// Decode hex signature to bytes for constant-time verify.
|
||||
// The outer catch handles any parsing or crypto errors.
|
||||
const hexPairs = Array.from(
|
||||
{ length: Math.ceil(parsed.value.length / 2) },
|
||||
(_, i) => parsed.value.slice(i * 2, i * 2 + 2)
|
||||
);
|
||||
const sigBytes = new Uint8Array(
|
||||
hexPairs.map((h) => Number.parseInt(h, 16))
|
||||
);
|
||||
return await globalThis.crypto.subtle.verify(
|
||||
"HMAC",
|
||||
key,
|
||||
sigBytes,
|
||||
enc.encode(payload)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a webhook signature header value of the form `"<algorithm>=<hex>"`.
|
||||
*
|
||||
* @param header - The raw signature header string.
|
||||
* @returns An object `{ algorithm, value }` or `null` if the format is invalid.
|
||||
*/
|
||||
export function parseSignatureHeader(
|
||||
header: string
|
||||
): { algorithm: string; value: string } | null {
|
||||
if (!header) return null;
|
||||
const eq = header.indexOf("=");
|
||||
if (eq <= 0 || eq === header.length - 1) return null;
|
||||
return { algorithm: header.slice(0, eq), value: header.slice(eq + 1) };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("@suaveplan/* runtime deps are exactly error + retry + types", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual(
|
||||
["@suaveplan/error", "@suaveplan/retry", "@suaveplan/types"].sort()
|
||||
);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual(
|
||||
[
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/retry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/webhook-client",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/retry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user