@nimbusnexus/webhooks-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/index.cjs +185 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +81 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.js +152 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NimbusNexus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @nimbusnexus/webhooks-sdk (TypeScript)
|
|
2
|
+
|
|
3
|
+
Official TypeScript SDK for **NimbusNexus Webhooks** — publish events, and verify the webhooks you
|
|
4
|
+
receive. Zero runtime dependencies (uses the built-in `fetch` and `node:crypto`); Node ≥ 20.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install @nimbusnexus/webhooks-sdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Verify an incoming webhook (subscribers)
|
|
11
|
+
|
|
12
|
+
Always verify the signature before trusting a webhook — it proves the request really came from
|
|
13
|
+
webhookd and wasn't tampered with or replayed. Pass the **raw** request body (do not re-serialize).
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { verify } from "@nimbusnexus/webhooks-sdk";
|
|
17
|
+
|
|
18
|
+
const ok = verify(endpointSigningSecret, rawBody, req.headers["x-webhook-signature"], {
|
|
19
|
+
timestamp: req.headers["x-webhook-timestamp"],
|
|
20
|
+
});
|
|
21
|
+
if (!ok) return res.status(400).end(); // forged, tampered, or outside the 300s replay window
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Publish an event (producers)
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { WebhookdClient, WebhookdApiError } from "@nimbusnexus/webhooks-sdk";
|
|
28
|
+
|
|
29
|
+
const wh = new WebhookdClient({ baseUrl: "https://webhooks.example.com", apiKey: "whsk_…" });
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const event = await wh.publish(
|
|
33
|
+
"order.created",
|
|
34
|
+
{ orderId: "ord_123", total: 4200 },
|
|
35
|
+
{ idempotencyKey: "order-123" }, // makes the publish safe to retry
|
|
36
|
+
);
|
|
37
|
+
console.log(event.eventUid, event.deliveriesCreated);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if (e instanceof WebhookdApiError) console.error(e.statusCode, e.code, e.message);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Transient failures (network errors, `429`, `5xx`) are retried with backoff (a `429` honours
|
|
44
|
+
`Retry-After`); other `4xx` throw `WebhookdApiError` carrying the `{error:{code,message}}` envelope.
|
|
45
|
+
|
|
46
|
+
## Develop
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm install
|
|
50
|
+
npm run typecheck && npm run lint && npm run test && npm run build
|
|
51
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
DEFAULT_TOLERANCE_SECONDS: () => DEFAULT_TOLERANCE_SECONDS,
|
|
24
|
+
VERSION: () => VERSION,
|
|
25
|
+
WebhookdApiError: () => WebhookdApiError,
|
|
26
|
+
WebhookdClient: () => WebhookdClient,
|
|
27
|
+
WebhookdError: () => WebhookdError,
|
|
28
|
+
sign: () => sign,
|
|
29
|
+
verify: () => verify
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(src_exports);
|
|
32
|
+
|
|
33
|
+
// src/signature.ts
|
|
34
|
+
var import_node_crypto = require("crypto");
|
|
35
|
+
var PREFIX = "sha256=";
|
|
36
|
+
var DEFAULT_TOLERANCE_SECONDS = 300;
|
|
37
|
+
function toBuffer(value) {
|
|
38
|
+
return Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
|
|
39
|
+
}
|
|
40
|
+
function signedBytes(rawBody, timestamp) {
|
|
41
|
+
if (timestamp === null) return rawBody;
|
|
42
|
+
return Buffer.concat([Buffer.from(`${timestamp}.`, "ascii"), rawBody]);
|
|
43
|
+
}
|
|
44
|
+
function sign(secret, rawBody, timestamp = null) {
|
|
45
|
+
const digest = (0, import_node_crypto.createHmac)("sha256", secret).update(signedBytes(toBuffer(rawBody), timestamp)).digest("hex");
|
|
46
|
+
return `${PREFIX}${digest}`;
|
|
47
|
+
}
|
|
48
|
+
function verify(secret, rawBody, signature, opts = {}) {
|
|
49
|
+
const { timestamp = null, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, now } = opts;
|
|
50
|
+
const ts = timestamp === null || timestamp === void 0 ? null : Number(timestamp);
|
|
51
|
+
if (ts !== null) {
|
|
52
|
+
if (Number.isNaN(ts)) return false;
|
|
53
|
+
const current = now ?? Math.floor(Date.now() / 1e3);
|
|
54
|
+
if (Math.abs(current - ts) > toleranceSeconds) return false;
|
|
55
|
+
}
|
|
56
|
+
const expected = Buffer.from(sign(secret, rawBody, ts));
|
|
57
|
+
const candidate = Buffer.from(signature.startsWith(PREFIX) ? signature : `${PREFIX}${signature}`);
|
|
58
|
+
return expected.length === candidate.length && (0, import_node_crypto.timingSafeEqual)(expected, candidate);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/errors.ts
|
|
62
|
+
var WebhookdError = class extends Error {
|
|
63
|
+
constructor(message) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = "WebhookdError";
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
var WebhookdApiError = class extends WebhookdError {
|
|
69
|
+
statusCode;
|
|
70
|
+
code;
|
|
71
|
+
constructor(statusCode, code, message) {
|
|
72
|
+
super(`[${statusCode} ${code}] ${message}`);
|
|
73
|
+
this.name = "WebhookdApiError";
|
|
74
|
+
this.statusCode = statusCode;
|
|
75
|
+
this.code = code;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// src/client.ts
|
|
80
|
+
var RETRY_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
81
|
+
var WebhookdClient = class {
|
|
82
|
+
baseUrl;
|
|
83
|
+
apiKey;
|
|
84
|
+
timeoutMs;
|
|
85
|
+
maxRetries;
|
|
86
|
+
fetchImpl;
|
|
87
|
+
constructor(opts) {
|
|
88
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
89
|
+
this.apiKey = opts.apiKey;
|
|
90
|
+
this.timeoutMs = opts.timeoutMs ?? 1e4;
|
|
91
|
+
this.maxRetries = opts.maxRetries ?? 2;
|
|
92
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
93
|
+
}
|
|
94
|
+
async publish(eventType, payload, opts = {}) {
|
|
95
|
+
const body = {
|
|
96
|
+
event_type: eventType,
|
|
97
|
+
payload,
|
|
98
|
+
environment: opts.environment ?? "prod",
|
|
99
|
+
application: opts.application ?? "default"
|
|
100
|
+
};
|
|
101
|
+
if (opts.source !== void 0) body.source = opts.source;
|
|
102
|
+
const headers = {
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
105
|
+
};
|
|
106
|
+
if (opts.idempotencyKey !== void 0) headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
107
|
+
const resp = await this.post("/v1/events", JSON.stringify(body), headers);
|
|
108
|
+
const data = await resp.json();
|
|
109
|
+
return {
|
|
110
|
+
id: String(data.id),
|
|
111
|
+
eventUid: String(data.event_uid),
|
|
112
|
+
eventType: String(data.event_type),
|
|
113
|
+
application: String(data.application ?? "default"),
|
|
114
|
+
environment: String(data.environment ?? "prod"),
|
|
115
|
+
deliveriesCreated: Number(data.deliveries_created ?? 0),
|
|
116
|
+
source: data.source ?? null
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async post(path, body, headers) {
|
|
120
|
+
const url = `${this.baseUrl}${path}`;
|
|
121
|
+
let lastErr;
|
|
122
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
123
|
+
let resp;
|
|
124
|
+
try {
|
|
125
|
+
resp = await this.fetchImpl(url, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers,
|
|
128
|
+
body,
|
|
129
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
lastErr = err;
|
|
133
|
+
if (attempt < this.maxRetries) {
|
|
134
|
+
await sleep(backoffMs(attempt));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
throw new WebhookdError(`request failed: ${String(err)}`);
|
|
138
|
+
}
|
|
139
|
+
if (RETRY_STATUSES.has(resp.status) && attempt < this.maxRetries) {
|
|
140
|
+
await sleep(retryAfterMs(resp) ?? backoffMs(attempt));
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (resp.status >= 400) throw await apiError(resp);
|
|
144
|
+
return resp;
|
|
145
|
+
}
|
|
146
|
+
throw new WebhookdError(`request failed after retries: ${String(lastErr)}`);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
function backoffMs(attempt) {
|
|
150
|
+
return Math.min(2e3, 200 * 2 ** attempt);
|
|
151
|
+
}
|
|
152
|
+
function retryAfterMs(resp) {
|
|
153
|
+
const raw = resp.headers.get("Retry-After");
|
|
154
|
+
if (raw && /^\d+$/.test(raw)) return Number(raw) * 1e3;
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
async function apiError(resp) {
|
|
158
|
+
let code = "error";
|
|
159
|
+
let message = "";
|
|
160
|
+
try {
|
|
161
|
+
const data = await resp.json();
|
|
162
|
+
code = data.error?.code ?? code;
|
|
163
|
+
message = data.error?.message ?? "";
|
|
164
|
+
} catch {
|
|
165
|
+
message = "";
|
|
166
|
+
}
|
|
167
|
+
return new WebhookdApiError(resp.status, code, message);
|
|
168
|
+
}
|
|
169
|
+
function sleep(ms) {
|
|
170
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/index.ts
|
|
174
|
+
var VERSION = "0.1.0";
|
|
175
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
176
|
+
0 && (module.exports = {
|
|
177
|
+
DEFAULT_TOLERANCE_SECONDS,
|
|
178
|
+
VERSION,
|
|
179
|
+
WebhookdApiError,
|
|
180
|
+
WebhookdClient,
|
|
181
|
+
WebhookdError,
|
|
182
|
+
sign,
|
|
183
|
+
verify
|
|
184
|
+
});
|
|
185
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/signature.ts","../src/errors.ts","../src/client.ts"],"sourcesContent":["/**\n * @nimbusnexus/webhooks-sdk — the official TypeScript client for NimbusNexus Webhooks.\n *\n * - `verify` — verify an incoming webhook's HMAC signature (for subscribers).\n * - `WebhookdClient` — publish events to webhookd (for producers).\n */\nexport { DEFAULT_TOLERANCE_SECONDS, sign, verify } from \"./signature\";\nexport type { VerifyOptions } from \"./signature\";\nexport { WebhookdClient } from \"./client\";\nexport type { ClientOptions, PublishOptions, WebhookdEvent } from \"./client\";\nexport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nexport const VERSION = \"0.1.0\";\n","/**\n * Verify webhookd webhook signatures.\n *\n * webhookd signs every delivery as `HMAC_SHA256(secret, \"<timestamp>.\" + rawBody)` (its default\n * timestamped mode) and sends `X-Webhook-Signature: sha256=<hex>` plus `X-Webhook-Timestamp`\n * (unix seconds). A subscriber MUST verify the signature to prove the request genuinely came from\n * webhookd and wasn't tampered with. Mirrors `delivery_core.webhook_outbox.{sign,verify}`.\n */\nimport { createHmac, timingSafeEqual } from \"node:crypto\";\n\nconst PREFIX = \"sha256=\";\nexport const DEFAULT_TOLERANCE_SECONDS = 300;\n\nfunction toBuffer(value: string | Buffer): Buffer {\n return Buffer.isBuffer(value) ? value : Buffer.from(value, \"utf8\");\n}\n\nfunction signedBytes(rawBody: Buffer, timestamp: number | null): Buffer {\n if (timestamp === null) return rawBody;\n return Buffer.concat([Buffer.from(`${timestamp}.`, \"ascii\"), rawBody]);\n}\n\n/** The `sha256=<hex>` signature webhookd would send for `rawBody` (+ optional timestamp). */\nexport function sign(\n secret: string | Buffer,\n rawBody: string | Buffer,\n timestamp: number | null = null,\n): string {\n const digest = createHmac(\"sha256\", secret)\n .update(signedBytes(toBuffer(rawBody), timestamp))\n .digest(\"hex\");\n return `${PREFIX}${digest}`;\n}\n\nexport interface VerifyOptions {\n /** The `X-Webhook-Timestamp` header value. When given, the replay window is enforced — always pass it. */\n timestamp?: number | string | null;\n /** Replay window in seconds; webhookd's default is 300. */\n toleranceSeconds?: number;\n /** Override the current unix time (for tests). */\n now?: number;\n}\n\n/**\n * Return `true` iff `signature` is a valid webhookd signature for `rawBody`.\n *\n * Pass the EXACT bytes you received as `rawBody` (a string or Buffer) — do not re-serialize the\n * JSON, or the signature won't match. Comparison is constant-time.\n */\nexport function verify(\n secret: string | Buffer,\n rawBody: string | Buffer,\n signature: string,\n opts: VerifyOptions = {},\n): boolean {\n const { timestamp = null, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, now } = opts;\n const ts = timestamp === null || timestamp === undefined ? null : Number(timestamp);\n if (ts !== null) {\n if (Number.isNaN(ts)) return false;\n const current = now ?? Math.floor(Date.now() / 1000);\n if (Math.abs(current - ts) > toleranceSeconds) return false;\n }\n const expected = Buffer.from(sign(secret, rawBody, ts));\n const candidate = Buffer.from(signature.startsWith(PREFIX) ? signature : `${PREFIX}${signature}`);\n // timingSafeEqual requires equal-length buffers; a length mismatch is already a non-match.\n return expected.length === candidate.length && timingSafeEqual(expected, candidate);\n}\n","/** Base class for all webhookd SDK errors (network failures, etc.). */\nexport class WebhookdError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookdError\";\n }\n}\n\n/**\n * A non-2xx response from the webhookd API. Carries the M5a error envelope: a stable machine\n * `code` (e.g. `\"rate_limited\"`, `\"not_found\"`, `\"validation_error\"`) and a human `message`,\n * plus the HTTP `statusCode`.\n */\nexport class WebhookdApiError extends WebhookdError {\n readonly statusCode: number;\n readonly code: string;\n\n constructor(statusCode: number, code: string, message: string) {\n super(`[${statusCode} ${code}] ${message}`);\n this.name = \"WebhookdApiError\";\n this.statusCode = statusCode;\n this.code = code;\n }\n}\n","/** Typed publish client for the webhookd API (zero runtime deps — uses the global `fetch`). */\nimport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nconst RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);\n\n/** The published event, as returned by `POST /v1/events` (webhookd's `EventOut`). */\nexport interface WebhookdEvent {\n id: string;\n eventUid: string;\n eventType: string;\n application: string;\n environment: string;\n deliveriesCreated: number;\n source: string | null;\n}\n\nexport interface ClientOptions {\n baseUrl: string;\n /** A per-tenant API key (`whsk_…`) or a service token. */\n apiKey: string;\n timeoutMs?: number;\n maxRetries?: number;\n /** Inject a `fetch` implementation (defaults to the global `fetch`); used for tests. */\n fetchImpl?: typeof fetch;\n}\n\nexport interface PublishOptions {\n environment?: string;\n application?: string;\n source?: string;\n /** Makes the publish safe to retry — a replay returns the original event without re-fanning-out. */\n idempotencyKey?: string;\n}\n\nexport class WebhookdClient {\n private readonly baseUrl: string;\n private readonly apiKey: string;\n private readonly timeoutMs: number;\n private readonly maxRetries: number;\n private readonly fetchImpl: typeof fetch;\n\n constructor(opts: ClientOptions) {\n this.baseUrl = opts.baseUrl.replace(/\\/+$/, \"\");\n this.apiKey = opts.apiKey;\n this.timeoutMs = opts.timeoutMs ?? 10_000;\n this.maxRetries = opts.maxRetries ?? 2;\n this.fetchImpl = opts.fetchImpl ?? fetch;\n }\n\n async publish(\n eventType: string,\n payload: Record<string, unknown>,\n opts: PublishOptions = {},\n ): Promise<WebhookdEvent> {\n const body: Record<string, unknown> = {\n event_type: eventType,\n payload,\n environment: opts.environment ?? \"prod\",\n application: opts.application ?? \"default\",\n };\n if (opts.source !== undefined) body.source = opts.source;\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${this.apiKey}`,\n };\n if (opts.idempotencyKey !== undefined) headers[\"Idempotency-Key\"] = opts.idempotencyKey;\n\n const resp = await this.post(\"/v1/events\", JSON.stringify(body), headers);\n const data = (await resp.json()) as Record<string, unknown>;\n return {\n id: String(data.id),\n eventUid: String(data.event_uid),\n eventType: String(data.event_type),\n application: String(data.application ?? \"default\"),\n environment: String(data.environment ?? \"prod\"),\n deliveriesCreated: Number(data.deliveries_created ?? 0),\n source: (data.source as string | null) ?? null,\n };\n }\n\n private async post(\n path: string,\n body: string,\n headers: Record<string, string>,\n ): Promise<Response> {\n const url = `${this.baseUrl}${path}`;\n let lastErr: unknown;\n for (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n let resp: Response;\n try {\n resp = await this.fetchImpl(url, {\n method: \"POST\",\n headers,\n body,\n signal: AbortSignal.timeout(this.timeoutMs),\n });\n } catch (err) {\n lastErr = err;\n if (attempt < this.maxRetries) {\n await sleep(backoffMs(attempt));\n continue;\n }\n throw new WebhookdError(`request failed: ${String(err)}`);\n }\n\n if (RETRY_STATUSES.has(resp.status) && attempt < this.maxRetries) {\n await sleep(retryAfterMs(resp) ?? backoffMs(attempt));\n continue;\n }\n if (resp.status >= 400) throw await apiError(resp);\n return resp;\n }\n throw new WebhookdError(`request failed after retries: ${String(lastErr)}`);\n }\n}\n\nfunction backoffMs(attempt: number): number {\n return Math.min(2000, 200 * 2 ** attempt);\n}\n\nfunction retryAfterMs(resp: Response): number | null {\n const raw = resp.headers.get(\"Retry-After\");\n if (raw && /^\\d+$/.test(raw)) return Number(raw) * 1000;\n return null;\n}\n\nasync function apiError(resp: Response): Promise<WebhookdApiError> {\n let code = \"error\";\n let message = \"\";\n try {\n const data = (await resp.json()) as { error?: { code?: string; message?: string } };\n code = data.error?.code ?? code;\n message = data.error?.message ?? \"\";\n } catch {\n message = \"\";\n }\n return new WebhookdApiError(resp.status, code, message);\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
declare const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
2
|
+
/** The `sha256=<hex>` signature webhookd would send for `rawBody` (+ optional timestamp). */
|
|
3
|
+
declare function sign(secret: string | Buffer, rawBody: string | Buffer, timestamp?: number | null): string;
|
|
4
|
+
interface VerifyOptions {
|
|
5
|
+
/** The `X-Webhook-Timestamp` header value. When given, the replay window is enforced — always pass it. */
|
|
6
|
+
timestamp?: number | string | null;
|
|
7
|
+
/** Replay window in seconds; webhookd's default is 300. */
|
|
8
|
+
toleranceSeconds?: number;
|
|
9
|
+
/** Override the current unix time (for tests). */
|
|
10
|
+
now?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Return `true` iff `signature` is a valid webhookd signature for `rawBody`.
|
|
14
|
+
*
|
|
15
|
+
* Pass the EXACT bytes you received as `rawBody` (a string or Buffer) — do not re-serialize the
|
|
16
|
+
* JSON, or the signature won't match. Comparison is constant-time.
|
|
17
|
+
*/
|
|
18
|
+
declare function verify(secret: string | Buffer, rawBody: string | Buffer, signature: string, opts?: VerifyOptions): boolean;
|
|
19
|
+
|
|
20
|
+
/** The published event, as returned by `POST /v1/events` (webhookd's `EventOut`). */
|
|
21
|
+
interface WebhookdEvent {
|
|
22
|
+
id: string;
|
|
23
|
+
eventUid: string;
|
|
24
|
+
eventType: string;
|
|
25
|
+
application: string;
|
|
26
|
+
environment: string;
|
|
27
|
+
deliveriesCreated: number;
|
|
28
|
+
source: string | null;
|
|
29
|
+
}
|
|
30
|
+
interface ClientOptions {
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
/** A per-tenant API key (`whsk_…`) or a service token. */
|
|
33
|
+
apiKey: string;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
maxRetries?: number;
|
|
36
|
+
/** Inject a `fetch` implementation (defaults to the global `fetch`); used for tests. */
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
}
|
|
39
|
+
interface PublishOptions {
|
|
40
|
+
environment?: string;
|
|
41
|
+
application?: string;
|
|
42
|
+
source?: string;
|
|
43
|
+
/** Makes the publish safe to retry — a replay returns the original event without re-fanning-out. */
|
|
44
|
+
idempotencyKey?: string;
|
|
45
|
+
}
|
|
46
|
+
declare class WebhookdClient {
|
|
47
|
+
private readonly baseUrl;
|
|
48
|
+
private readonly apiKey;
|
|
49
|
+
private readonly timeoutMs;
|
|
50
|
+
private readonly maxRetries;
|
|
51
|
+
private readonly fetchImpl;
|
|
52
|
+
constructor(opts: ClientOptions);
|
|
53
|
+
publish(eventType: string, payload: Record<string, unknown>, opts?: PublishOptions): Promise<WebhookdEvent>;
|
|
54
|
+
private post;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Base class for all webhookd SDK errors (network failures, etc.). */
|
|
58
|
+
declare class WebhookdError extends Error {
|
|
59
|
+
constructor(message: string);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A non-2xx response from the webhookd API. Carries the M5a error envelope: a stable machine
|
|
63
|
+
* `code` (e.g. `"rate_limited"`, `"not_found"`, `"validation_error"`) and a human `message`,
|
|
64
|
+
* plus the HTTP `statusCode`.
|
|
65
|
+
*/
|
|
66
|
+
declare class WebhookdApiError extends WebhookdError {
|
|
67
|
+
readonly statusCode: number;
|
|
68
|
+
readonly code: string;
|
|
69
|
+
constructor(statusCode: number, code: string, message: string);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @nimbusnexus/webhooks-sdk — the official TypeScript client for NimbusNexus Webhooks.
|
|
74
|
+
*
|
|
75
|
+
* - `verify` — verify an incoming webhook's HMAC signature (for subscribers).
|
|
76
|
+
* - `WebhookdClient` — publish events to webhookd (for producers).
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
declare const VERSION = "0.1.0";
|
|
80
|
+
|
|
81
|
+
export { type ClientOptions, DEFAULT_TOLERANCE_SECONDS, type PublishOptions, VERSION, type VerifyOptions, WebhookdApiError, WebhookdClient, WebhookdError, type WebhookdEvent, sign, verify };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
declare const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
2
|
+
/** The `sha256=<hex>` signature webhookd would send for `rawBody` (+ optional timestamp). */
|
|
3
|
+
declare function sign(secret: string | Buffer, rawBody: string | Buffer, timestamp?: number | null): string;
|
|
4
|
+
interface VerifyOptions {
|
|
5
|
+
/** The `X-Webhook-Timestamp` header value. When given, the replay window is enforced — always pass it. */
|
|
6
|
+
timestamp?: number | string | null;
|
|
7
|
+
/** Replay window in seconds; webhookd's default is 300. */
|
|
8
|
+
toleranceSeconds?: number;
|
|
9
|
+
/** Override the current unix time (for tests). */
|
|
10
|
+
now?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Return `true` iff `signature` is a valid webhookd signature for `rawBody`.
|
|
14
|
+
*
|
|
15
|
+
* Pass the EXACT bytes you received as `rawBody` (a string or Buffer) — do not re-serialize the
|
|
16
|
+
* JSON, or the signature won't match. Comparison is constant-time.
|
|
17
|
+
*/
|
|
18
|
+
declare function verify(secret: string | Buffer, rawBody: string | Buffer, signature: string, opts?: VerifyOptions): boolean;
|
|
19
|
+
|
|
20
|
+
/** The published event, as returned by `POST /v1/events` (webhookd's `EventOut`). */
|
|
21
|
+
interface WebhookdEvent {
|
|
22
|
+
id: string;
|
|
23
|
+
eventUid: string;
|
|
24
|
+
eventType: string;
|
|
25
|
+
application: string;
|
|
26
|
+
environment: string;
|
|
27
|
+
deliveriesCreated: number;
|
|
28
|
+
source: string | null;
|
|
29
|
+
}
|
|
30
|
+
interface ClientOptions {
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
/** A per-tenant API key (`whsk_…`) or a service token. */
|
|
33
|
+
apiKey: string;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
maxRetries?: number;
|
|
36
|
+
/** Inject a `fetch` implementation (defaults to the global `fetch`); used for tests. */
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
}
|
|
39
|
+
interface PublishOptions {
|
|
40
|
+
environment?: string;
|
|
41
|
+
application?: string;
|
|
42
|
+
source?: string;
|
|
43
|
+
/** Makes the publish safe to retry — a replay returns the original event without re-fanning-out. */
|
|
44
|
+
idempotencyKey?: string;
|
|
45
|
+
}
|
|
46
|
+
declare class WebhookdClient {
|
|
47
|
+
private readonly baseUrl;
|
|
48
|
+
private readonly apiKey;
|
|
49
|
+
private readonly timeoutMs;
|
|
50
|
+
private readonly maxRetries;
|
|
51
|
+
private readonly fetchImpl;
|
|
52
|
+
constructor(opts: ClientOptions);
|
|
53
|
+
publish(eventType: string, payload: Record<string, unknown>, opts?: PublishOptions): Promise<WebhookdEvent>;
|
|
54
|
+
private post;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Base class for all webhookd SDK errors (network failures, etc.). */
|
|
58
|
+
declare class WebhookdError extends Error {
|
|
59
|
+
constructor(message: string);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A non-2xx response from the webhookd API. Carries the M5a error envelope: a stable machine
|
|
63
|
+
* `code` (e.g. `"rate_limited"`, `"not_found"`, `"validation_error"`) and a human `message`,
|
|
64
|
+
* plus the HTTP `statusCode`.
|
|
65
|
+
*/
|
|
66
|
+
declare class WebhookdApiError extends WebhookdError {
|
|
67
|
+
readonly statusCode: number;
|
|
68
|
+
readonly code: string;
|
|
69
|
+
constructor(statusCode: number, code: string, message: string);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @nimbusnexus/webhooks-sdk — the official TypeScript client for NimbusNexus Webhooks.
|
|
74
|
+
*
|
|
75
|
+
* - `verify` — verify an incoming webhook's HMAC signature (for subscribers).
|
|
76
|
+
* - `WebhookdClient` — publish events to webhookd (for producers).
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
declare const VERSION = "0.1.0";
|
|
80
|
+
|
|
81
|
+
export { type ClientOptions, DEFAULT_TOLERANCE_SECONDS, type PublishOptions, VERSION, type VerifyOptions, WebhookdApiError, WebhookdClient, WebhookdError, type WebhookdEvent, sign, verify };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// src/signature.ts
|
|
2
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
3
|
+
var PREFIX = "sha256=";
|
|
4
|
+
var DEFAULT_TOLERANCE_SECONDS = 300;
|
|
5
|
+
function toBuffer(value) {
|
|
6
|
+
return Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
|
|
7
|
+
}
|
|
8
|
+
function signedBytes(rawBody, timestamp) {
|
|
9
|
+
if (timestamp === null) return rawBody;
|
|
10
|
+
return Buffer.concat([Buffer.from(`${timestamp}.`, "ascii"), rawBody]);
|
|
11
|
+
}
|
|
12
|
+
function sign(secret, rawBody, timestamp = null) {
|
|
13
|
+
const digest = createHmac("sha256", secret).update(signedBytes(toBuffer(rawBody), timestamp)).digest("hex");
|
|
14
|
+
return `${PREFIX}${digest}`;
|
|
15
|
+
}
|
|
16
|
+
function verify(secret, rawBody, signature, opts = {}) {
|
|
17
|
+
const { timestamp = null, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, now } = opts;
|
|
18
|
+
const ts = timestamp === null || timestamp === void 0 ? null : Number(timestamp);
|
|
19
|
+
if (ts !== null) {
|
|
20
|
+
if (Number.isNaN(ts)) return false;
|
|
21
|
+
const current = now ?? Math.floor(Date.now() / 1e3);
|
|
22
|
+
if (Math.abs(current - ts) > toleranceSeconds) return false;
|
|
23
|
+
}
|
|
24
|
+
const expected = Buffer.from(sign(secret, rawBody, ts));
|
|
25
|
+
const candidate = Buffer.from(signature.startsWith(PREFIX) ? signature : `${PREFIX}${signature}`);
|
|
26
|
+
return expected.length === candidate.length && timingSafeEqual(expected, candidate);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/errors.ts
|
|
30
|
+
var WebhookdError = class extends Error {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "WebhookdError";
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var WebhookdApiError = class extends WebhookdError {
|
|
37
|
+
statusCode;
|
|
38
|
+
code;
|
|
39
|
+
constructor(statusCode, code, message) {
|
|
40
|
+
super(`[${statusCode} ${code}] ${message}`);
|
|
41
|
+
this.name = "WebhookdApiError";
|
|
42
|
+
this.statusCode = statusCode;
|
|
43
|
+
this.code = code;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// src/client.ts
|
|
48
|
+
var RETRY_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
49
|
+
var WebhookdClient = class {
|
|
50
|
+
baseUrl;
|
|
51
|
+
apiKey;
|
|
52
|
+
timeoutMs;
|
|
53
|
+
maxRetries;
|
|
54
|
+
fetchImpl;
|
|
55
|
+
constructor(opts) {
|
|
56
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
57
|
+
this.apiKey = opts.apiKey;
|
|
58
|
+
this.timeoutMs = opts.timeoutMs ?? 1e4;
|
|
59
|
+
this.maxRetries = opts.maxRetries ?? 2;
|
|
60
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
61
|
+
}
|
|
62
|
+
async publish(eventType, payload, opts = {}) {
|
|
63
|
+
const body = {
|
|
64
|
+
event_type: eventType,
|
|
65
|
+
payload,
|
|
66
|
+
environment: opts.environment ?? "prod",
|
|
67
|
+
application: opts.application ?? "default"
|
|
68
|
+
};
|
|
69
|
+
if (opts.source !== void 0) body.source = opts.source;
|
|
70
|
+
const headers = {
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
73
|
+
};
|
|
74
|
+
if (opts.idempotencyKey !== void 0) headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
75
|
+
const resp = await this.post("/v1/events", JSON.stringify(body), headers);
|
|
76
|
+
const data = await resp.json();
|
|
77
|
+
return {
|
|
78
|
+
id: String(data.id),
|
|
79
|
+
eventUid: String(data.event_uid),
|
|
80
|
+
eventType: String(data.event_type),
|
|
81
|
+
application: String(data.application ?? "default"),
|
|
82
|
+
environment: String(data.environment ?? "prod"),
|
|
83
|
+
deliveriesCreated: Number(data.deliveries_created ?? 0),
|
|
84
|
+
source: data.source ?? null
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async post(path, body, headers) {
|
|
88
|
+
const url = `${this.baseUrl}${path}`;
|
|
89
|
+
let lastErr;
|
|
90
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
91
|
+
let resp;
|
|
92
|
+
try {
|
|
93
|
+
resp = await this.fetchImpl(url, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers,
|
|
96
|
+
body,
|
|
97
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
98
|
+
});
|
|
99
|
+
} catch (err) {
|
|
100
|
+
lastErr = err;
|
|
101
|
+
if (attempt < this.maxRetries) {
|
|
102
|
+
await sleep(backoffMs(attempt));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
throw new WebhookdError(`request failed: ${String(err)}`);
|
|
106
|
+
}
|
|
107
|
+
if (RETRY_STATUSES.has(resp.status) && attempt < this.maxRetries) {
|
|
108
|
+
await sleep(retryAfterMs(resp) ?? backoffMs(attempt));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (resp.status >= 400) throw await apiError(resp);
|
|
112
|
+
return resp;
|
|
113
|
+
}
|
|
114
|
+
throw new WebhookdError(`request failed after retries: ${String(lastErr)}`);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
function backoffMs(attempt) {
|
|
118
|
+
return Math.min(2e3, 200 * 2 ** attempt);
|
|
119
|
+
}
|
|
120
|
+
function retryAfterMs(resp) {
|
|
121
|
+
const raw = resp.headers.get("Retry-After");
|
|
122
|
+
if (raw && /^\d+$/.test(raw)) return Number(raw) * 1e3;
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
async function apiError(resp) {
|
|
126
|
+
let code = "error";
|
|
127
|
+
let message = "";
|
|
128
|
+
try {
|
|
129
|
+
const data = await resp.json();
|
|
130
|
+
code = data.error?.code ?? code;
|
|
131
|
+
message = data.error?.message ?? "";
|
|
132
|
+
} catch {
|
|
133
|
+
message = "";
|
|
134
|
+
}
|
|
135
|
+
return new WebhookdApiError(resp.status, code, message);
|
|
136
|
+
}
|
|
137
|
+
function sleep(ms) {
|
|
138
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/index.ts
|
|
142
|
+
var VERSION = "0.1.0";
|
|
143
|
+
export {
|
|
144
|
+
DEFAULT_TOLERANCE_SECONDS,
|
|
145
|
+
VERSION,
|
|
146
|
+
WebhookdApiError,
|
|
147
|
+
WebhookdClient,
|
|
148
|
+
WebhookdError,
|
|
149
|
+
sign,
|
|
150
|
+
verify
|
|
151
|
+
};
|
|
152
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/signature.ts","../src/errors.ts","../src/client.ts","../src/index.ts"],"sourcesContent":["/**\n * Verify webhookd webhook signatures.\n *\n * webhookd signs every delivery as `HMAC_SHA256(secret, \"<timestamp>.\" + rawBody)` (its default\n * timestamped mode) and sends `X-Webhook-Signature: sha256=<hex>` plus `X-Webhook-Timestamp`\n * (unix seconds). A subscriber MUST verify the signature to prove the request genuinely came from\n * webhookd and wasn't tampered with. Mirrors `delivery_core.webhook_outbox.{sign,verify}`.\n */\nimport { createHmac, timingSafeEqual } from \"node:crypto\";\n\nconst PREFIX = \"sha256=\";\nexport const DEFAULT_TOLERANCE_SECONDS = 300;\n\nfunction toBuffer(value: string | Buffer): Buffer {\n return Buffer.isBuffer(value) ? value : Buffer.from(value, \"utf8\");\n}\n\nfunction signedBytes(rawBody: Buffer, timestamp: number | null): Buffer {\n if (timestamp === null) return rawBody;\n return Buffer.concat([Buffer.from(`${timestamp}.`, \"ascii\"), rawBody]);\n}\n\n/** The `sha256=<hex>` signature webhookd would send for `rawBody` (+ optional timestamp). */\nexport function sign(\n secret: string | Buffer,\n rawBody: string | Buffer,\n timestamp: number | null = null,\n): string {\n const digest = createHmac(\"sha256\", secret)\n .update(signedBytes(toBuffer(rawBody), timestamp))\n .digest(\"hex\");\n return `${PREFIX}${digest}`;\n}\n\nexport interface VerifyOptions {\n /** The `X-Webhook-Timestamp` header value. When given, the replay window is enforced — always pass it. */\n timestamp?: number | string | null;\n /** Replay window in seconds; webhookd's default is 300. */\n toleranceSeconds?: number;\n /** Override the current unix time (for tests). */\n now?: number;\n}\n\n/**\n * Return `true` iff `signature` is a valid webhookd signature for `rawBody`.\n *\n * Pass the EXACT bytes you received as `rawBody` (a string or Buffer) — do not re-serialize the\n * JSON, or the signature won't match. Comparison is constant-time.\n */\nexport function verify(\n secret: string | Buffer,\n rawBody: string | Buffer,\n signature: string,\n opts: VerifyOptions = {},\n): boolean {\n const { timestamp = null, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, now } = opts;\n const ts = timestamp === null || timestamp === undefined ? null : Number(timestamp);\n if (ts !== null) {\n if (Number.isNaN(ts)) return false;\n const current = now ?? Math.floor(Date.now() / 1000);\n if (Math.abs(current - ts) > toleranceSeconds) return false;\n }\n const expected = Buffer.from(sign(secret, rawBody, ts));\n const candidate = Buffer.from(signature.startsWith(PREFIX) ? signature : `${PREFIX}${signature}`);\n // timingSafeEqual requires equal-length buffers; a length mismatch is already a non-match.\n return expected.length === candidate.length && timingSafeEqual(expected, candidate);\n}\n","/** Base class for all webhookd SDK errors (network failures, etc.). */\nexport class WebhookdError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookdError\";\n }\n}\n\n/**\n * A non-2xx response from the webhookd API. Carries the M5a error envelope: a stable machine\n * `code` (e.g. `\"rate_limited\"`, `\"not_found\"`, `\"validation_error\"`) and a human `message`,\n * plus the HTTP `statusCode`.\n */\nexport class WebhookdApiError extends WebhookdError {\n readonly statusCode: number;\n readonly code: string;\n\n constructor(statusCode: number, code: string, message: string) {\n super(`[${statusCode} ${code}] ${message}`);\n this.name = \"WebhookdApiError\";\n this.statusCode = statusCode;\n this.code = code;\n }\n}\n","/** Typed publish client for the webhookd API (zero runtime deps — uses the global `fetch`). */\nimport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nconst RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);\n\n/** The published event, as returned by `POST /v1/events` (webhookd's `EventOut`). */\nexport interface WebhookdEvent {\n id: string;\n eventUid: string;\n eventType: string;\n application: string;\n environment: string;\n deliveriesCreated: number;\n source: string | null;\n}\n\nexport interface ClientOptions {\n baseUrl: string;\n /** A per-tenant API key (`whsk_…`) or a service token. */\n apiKey: string;\n timeoutMs?: number;\n maxRetries?: number;\n /** Inject a `fetch` implementation (defaults to the global `fetch`); used for tests. */\n fetchImpl?: typeof fetch;\n}\n\nexport interface PublishOptions {\n environment?: string;\n application?: string;\n source?: string;\n /** Makes the publish safe to retry — a replay returns the original event without re-fanning-out. */\n idempotencyKey?: string;\n}\n\nexport class WebhookdClient {\n private readonly baseUrl: string;\n private readonly apiKey: string;\n private readonly timeoutMs: number;\n private readonly maxRetries: number;\n private readonly fetchImpl: typeof fetch;\n\n constructor(opts: ClientOptions) {\n this.baseUrl = opts.baseUrl.replace(/\\/+$/, \"\");\n this.apiKey = opts.apiKey;\n this.timeoutMs = opts.timeoutMs ?? 10_000;\n this.maxRetries = opts.maxRetries ?? 2;\n this.fetchImpl = opts.fetchImpl ?? fetch;\n }\n\n async publish(\n eventType: string,\n payload: Record<string, unknown>,\n opts: PublishOptions = {},\n ): Promise<WebhookdEvent> {\n const body: Record<string, unknown> = {\n event_type: eventType,\n payload,\n environment: opts.environment ?? \"prod\",\n application: opts.application ?? \"default\",\n };\n if (opts.source !== undefined) body.source = opts.source;\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${this.apiKey}`,\n };\n if (opts.idempotencyKey !== undefined) headers[\"Idempotency-Key\"] = opts.idempotencyKey;\n\n const resp = await this.post(\"/v1/events\", JSON.stringify(body), headers);\n const data = (await resp.json()) as Record<string, unknown>;\n return {\n id: String(data.id),\n eventUid: String(data.event_uid),\n eventType: String(data.event_type),\n application: String(data.application ?? \"default\"),\n environment: String(data.environment ?? \"prod\"),\n deliveriesCreated: Number(data.deliveries_created ?? 0),\n source: (data.source as string | null) ?? null,\n };\n }\n\n private async post(\n path: string,\n body: string,\n headers: Record<string, string>,\n ): Promise<Response> {\n const url = `${this.baseUrl}${path}`;\n let lastErr: unknown;\n for (let attempt = 0; attempt <= this.maxRetries; attempt++) {\n let resp: Response;\n try {\n resp = await this.fetchImpl(url, {\n method: \"POST\",\n headers,\n body,\n signal: AbortSignal.timeout(this.timeoutMs),\n });\n } catch (err) {\n lastErr = err;\n if (attempt < this.maxRetries) {\n await sleep(backoffMs(attempt));\n continue;\n }\n throw new WebhookdError(`request failed: ${String(err)}`);\n }\n\n if (RETRY_STATUSES.has(resp.status) && attempt < this.maxRetries) {\n await sleep(retryAfterMs(resp) ?? backoffMs(attempt));\n continue;\n }\n if (resp.status >= 400) throw await apiError(resp);\n return resp;\n }\n throw new WebhookdError(`request failed after retries: ${String(lastErr)}`);\n }\n}\n\nfunction backoffMs(attempt: number): number {\n return Math.min(2000, 200 * 2 ** attempt);\n}\n\nfunction retryAfterMs(resp: Response): number | null {\n const raw = resp.headers.get(\"Retry-After\");\n if (raw && /^\\d+$/.test(raw)) return Number(raw) * 1000;\n return null;\n}\n\nasync function apiError(resp: Response): Promise<WebhookdApiError> {\n let code = \"error\";\n let message = \"\";\n try {\n const data = (await resp.json()) as { error?: { code?: string; message?: string } };\n code = data.error?.code ?? code;\n message = data.error?.message ?? \"\";\n } catch {\n message = \"\";\n }\n return new WebhookdApiError(resp.status, code, message);\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * @nimbusnexus/webhooks-sdk — the official TypeScript client for NimbusNexus Webhooks.\n *\n * - `verify` — verify an incoming webhook's HMAC signature (for subscribers).\n * - `WebhookdClient` — publish events to webhookd (for producers).\n */\nexport { DEFAULT_TOLERANCE_SECONDS, sign, verify } from \"./signature\";\nexport type { VerifyOptions } from \"./signature\";\nexport { WebhookdClient } from \"./client\";\nexport type { ClientOptions, PublishOptions, WebhookdEvent } from \"./client\";\nexport { WebhookdApiError, WebhookdError } from \"./errors\";\n\nexport const VERSION = \"0.1.0\";\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nimbusnexus/webhooks-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Official TypeScript SDK for NimbusNexus Webhooks — publish events + verify webhook signatures.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "NimbusNexus",
|
|
8
|
+
"homepage": "https://github.com/NimbusNexus/Webhooks-sdks/tree/develop/typescript#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/NimbusNexus/Webhooks-sdks.git",
|
|
12
|
+
"directory": "typescript"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/NimbusNexus/Webhooks-sdks/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": ["webhook", "webhooks", "hmac", "signature", "verify", "nimbusnexus", "sdk"],
|
|
18
|
+
"main": "./dist/index.cjs",
|
|
19
|
+
"module": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"import": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"require": {
|
|
28
|
+
"types": "./dist/index.d.cts",
|
|
29
|
+
"default": "./dist/index.cjs"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": ["dist", "LICENSE", "README.md"],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsup",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"lint": "eslint .",
|
|
41
|
+
"lint:fix": "eslint . --fix",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"prepublishOnly": "npm run typecheck && npm run lint && npm run test && npm run build"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@eslint/js": "^9.18.0",
|
|
50
|
+
"@types/node": "^20.19.0",
|
|
51
|
+
"eslint": "^9.18.0",
|
|
52
|
+
"eslint-config-prettier": "^9.1.2",
|
|
53
|
+
"globals": "^15.14.0",
|
|
54
|
+
"prettier": "^3.4.2",
|
|
55
|
+
"tsup": "^8.3.5",
|
|
56
|
+
"typescript": "~5.7.3",
|
|
57
|
+
"typescript-eslint": "^8.20.0",
|
|
58
|
+
"vitest": "^2.1.8"
|
|
59
|
+
}
|
|
60
|
+
}
|