@webhooks-cc/sdk 0.1.1 → 0.3.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/README.md +138 -45
- package/dist/index.d.mts +225 -13
- package/dist/index.d.ts +225 -13
- package/dist/index.js +631 -29
- package/dist/index.mjs +609 -28
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -21,22 +21,222 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
ApiError: () => ApiError,
|
|
24
|
-
|
|
24
|
+
NotFoundError: () => NotFoundError,
|
|
25
|
+
RateLimitError: () => RateLimitError,
|
|
26
|
+
TimeoutError: () => TimeoutError,
|
|
27
|
+
UnauthorizedError: () => UnauthorizedError,
|
|
28
|
+
WebhooksCC: () => WebhooksCC,
|
|
29
|
+
WebhooksCCError: () => WebhooksCCError,
|
|
30
|
+
isGitHubWebhook: () => isGitHubWebhook,
|
|
31
|
+
isLinearWebhook: () => isLinearWebhook,
|
|
32
|
+
isPaddleWebhook: () => isPaddleWebhook,
|
|
33
|
+
isShopifyWebhook: () => isShopifyWebhook,
|
|
34
|
+
isSlackWebhook: () => isSlackWebhook,
|
|
35
|
+
isStripeWebhook: () => isStripeWebhook,
|
|
36
|
+
isTwilioWebhook: () => isTwilioWebhook,
|
|
37
|
+
matchAll: () => matchAll,
|
|
38
|
+
matchAny: () => matchAny,
|
|
39
|
+
matchBodyPath: () => matchBodyPath,
|
|
40
|
+
matchHeader: () => matchHeader,
|
|
41
|
+
matchJsonField: () => matchJsonField,
|
|
42
|
+
matchMethod: () => matchMethod,
|
|
43
|
+
parseDuration: () => parseDuration,
|
|
44
|
+
parseJsonBody: () => parseJsonBody,
|
|
45
|
+
parseSSE: () => parseSSE
|
|
25
46
|
});
|
|
26
47
|
module.exports = __toCommonJS(index_exports);
|
|
27
48
|
|
|
49
|
+
// src/errors.ts
|
|
50
|
+
var WebhooksCCError = class extends Error {
|
|
51
|
+
constructor(statusCode, message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.statusCode = statusCode;
|
|
54
|
+
this.name = "WebhooksCCError";
|
|
55
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
var UnauthorizedError = class extends WebhooksCCError {
|
|
59
|
+
constructor(message = "Invalid or missing API key") {
|
|
60
|
+
super(401, message);
|
|
61
|
+
this.name = "UnauthorizedError";
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var NotFoundError = class extends WebhooksCCError {
|
|
65
|
+
constructor(message = "Resource not found") {
|
|
66
|
+
super(404, message);
|
|
67
|
+
this.name = "NotFoundError";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var TimeoutError = class extends WebhooksCCError {
|
|
71
|
+
constructor(timeoutMs) {
|
|
72
|
+
super(0, `Request timed out after ${timeoutMs}ms`);
|
|
73
|
+
this.name = "TimeoutError";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
var RateLimitError = class extends WebhooksCCError {
|
|
77
|
+
constructor(retryAfter) {
|
|
78
|
+
const message = retryAfter ? `Rate limited, retry after ${retryAfter}s` : "Rate limited";
|
|
79
|
+
super(429, message);
|
|
80
|
+
this.name = "RateLimitError";
|
|
81
|
+
this.retryAfter = retryAfter;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/utils.ts
|
|
86
|
+
var DURATION_REGEX = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)$/;
|
|
87
|
+
function parseDuration(input) {
|
|
88
|
+
if (typeof input === "number") {
|
|
89
|
+
if (!Number.isFinite(input) || input < 0) {
|
|
90
|
+
throw new Error(`Invalid duration: must be a finite non-negative number, got ${input}`);
|
|
91
|
+
}
|
|
92
|
+
return input;
|
|
93
|
+
}
|
|
94
|
+
const trimmed = input.trim();
|
|
95
|
+
const asNumber = Number(trimmed);
|
|
96
|
+
if (!isNaN(asNumber) && trimmed.length > 0) {
|
|
97
|
+
if (!Number.isFinite(asNumber) || asNumber < 0) {
|
|
98
|
+
throw new Error(`Invalid duration: must be a finite non-negative number, got "${input}"`);
|
|
99
|
+
}
|
|
100
|
+
return asNumber;
|
|
101
|
+
}
|
|
102
|
+
const match = DURATION_REGEX.exec(trimmed);
|
|
103
|
+
if (!match) {
|
|
104
|
+
throw new Error(`Invalid duration: "${input}"`);
|
|
105
|
+
}
|
|
106
|
+
const value = parseFloat(match[1]);
|
|
107
|
+
switch (match[2]) {
|
|
108
|
+
case "ms":
|
|
109
|
+
return value;
|
|
110
|
+
case "s":
|
|
111
|
+
return value * 1e3;
|
|
112
|
+
case "m":
|
|
113
|
+
return value * 6e4;
|
|
114
|
+
case "h":
|
|
115
|
+
return value * 36e5;
|
|
116
|
+
default:
|
|
117
|
+
throw new Error(`Invalid duration: "${input}"`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/sse.ts
|
|
122
|
+
async function* parseSSE(stream) {
|
|
123
|
+
const reader = stream.getReader();
|
|
124
|
+
const decoder = new TextDecoder();
|
|
125
|
+
let buffer = "";
|
|
126
|
+
let currentEvent = "message";
|
|
127
|
+
let dataLines = [];
|
|
128
|
+
try {
|
|
129
|
+
while (true) {
|
|
130
|
+
const { done, value } = await reader.read();
|
|
131
|
+
if (done) break;
|
|
132
|
+
buffer += decoder.decode(value, { stream: true });
|
|
133
|
+
const lines = buffer.split("\n");
|
|
134
|
+
buffer = lines.pop();
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
if (line === "" || line === "\r") {
|
|
137
|
+
if (dataLines.length > 0) {
|
|
138
|
+
yield { event: currentEvent, data: dataLines.join("\n") };
|
|
139
|
+
dataLines = [];
|
|
140
|
+
currentEvent = "message";
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const trimmedLine = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
145
|
+
if (trimmedLine.startsWith(":")) {
|
|
146
|
+
const rawComment = trimmedLine.slice(1);
|
|
147
|
+
yield {
|
|
148
|
+
event: "comment",
|
|
149
|
+
data: rawComment.startsWith(" ") ? rawComment.slice(1) : rawComment
|
|
150
|
+
};
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const colonIdx = trimmedLine.indexOf(":");
|
|
154
|
+
if (colonIdx === -1) continue;
|
|
155
|
+
const field = trimmedLine.slice(0, colonIdx);
|
|
156
|
+
const rawVal = trimmedLine.slice(colonIdx + 1);
|
|
157
|
+
const val = rawVal.startsWith(" ") ? rawVal.slice(1) : rawVal;
|
|
158
|
+
switch (field) {
|
|
159
|
+
case "event":
|
|
160
|
+
currentEvent = val;
|
|
161
|
+
break;
|
|
162
|
+
case "data":
|
|
163
|
+
dataLines.push(val);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (buffer.length > 0) {
|
|
169
|
+
const trimmedLine = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
|
170
|
+
if (trimmedLine.startsWith(":")) {
|
|
171
|
+
const rawComment = trimmedLine.slice(1);
|
|
172
|
+
yield {
|
|
173
|
+
event: "comment",
|
|
174
|
+
data: rawComment.startsWith(" ") ? rawComment.slice(1) : rawComment
|
|
175
|
+
};
|
|
176
|
+
} else {
|
|
177
|
+
const colonIdx = trimmedLine.indexOf(":");
|
|
178
|
+
if (colonIdx !== -1) {
|
|
179
|
+
const field = trimmedLine.slice(0, colonIdx);
|
|
180
|
+
const rawVal = trimmedLine.slice(colonIdx + 1);
|
|
181
|
+
const val = rawVal.startsWith(" ") ? rawVal.slice(1) : rawVal;
|
|
182
|
+
if (field === "event") currentEvent = val;
|
|
183
|
+
else if (field === "data") dataLines.push(val);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (dataLines.length > 0) {
|
|
188
|
+
yield { event: currentEvent, data: dataLines.join("\n") };
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
await reader.cancel();
|
|
192
|
+
reader.releaseLock();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
28
196
|
// src/client.ts
|
|
29
197
|
var DEFAULT_BASE_URL = "https://webhooks.cc";
|
|
198
|
+
var DEFAULT_WEBHOOK_URL = "https://go.webhooks.cc";
|
|
30
199
|
var DEFAULT_TIMEOUT = 3e4;
|
|
200
|
+
var SDK_VERSION = "0.3.0";
|
|
31
201
|
var MIN_POLL_INTERVAL = 10;
|
|
32
202
|
var MAX_POLL_INTERVAL = 6e4;
|
|
33
|
-
var
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
203
|
+
var ALLOWED_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]);
|
|
204
|
+
var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
205
|
+
"host",
|
|
206
|
+
"connection",
|
|
207
|
+
"content-length",
|
|
208
|
+
"transfer-encoding",
|
|
209
|
+
"keep-alive",
|
|
210
|
+
"te",
|
|
211
|
+
"trailer",
|
|
212
|
+
"upgrade"
|
|
213
|
+
]);
|
|
214
|
+
var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "cookie", "proxy-authorization", "set-cookie"]);
|
|
215
|
+
var ApiError = WebhooksCCError;
|
|
216
|
+
function mapStatusToError(status, message, response) {
|
|
217
|
+
const isGeneric = message.length < 30;
|
|
218
|
+
switch (status) {
|
|
219
|
+
case 401: {
|
|
220
|
+
const hint = isGeneric ? `${message} \u2014 Get an API key at https://webhooks.cc/account` : message;
|
|
221
|
+
return new UnauthorizedError(hint);
|
|
222
|
+
}
|
|
223
|
+
case 404: {
|
|
224
|
+
const hint = isGeneric ? `${message} \u2014 Use client.endpoints.list() to see available endpoints.` : message;
|
|
225
|
+
return new NotFoundError(hint);
|
|
226
|
+
}
|
|
227
|
+
case 429: {
|
|
228
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
229
|
+
let retryAfter;
|
|
230
|
+
if (retryAfterHeader) {
|
|
231
|
+
const parsed = parseInt(retryAfterHeader, 10);
|
|
232
|
+
retryAfter = Number.isNaN(parsed) ? void 0 : parsed;
|
|
233
|
+
}
|
|
234
|
+
return new RateLimitError(retryAfter);
|
|
235
|
+
}
|
|
236
|
+
default:
|
|
237
|
+
return new WebhooksCCError(status, message);
|
|
38
238
|
}
|
|
39
|
-
}
|
|
239
|
+
}
|
|
40
240
|
var SAFE_PATH_SEGMENT_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
41
241
|
function validatePathSegment(segment, name) {
|
|
42
242
|
if (!SAFE_PATH_SEGMENT_REGEX.test(segment)) {
|
|
@@ -58,9 +258,44 @@ var WebhooksCC = class {
|
|
|
58
258
|
validatePathSegment(slug, "slug");
|
|
59
259
|
return this.request("GET", `/endpoints/${slug}`);
|
|
60
260
|
},
|
|
261
|
+
update: async (slug, options) => {
|
|
262
|
+
validatePathSegment(slug, "slug");
|
|
263
|
+
if (options.mockResponse && options.mockResponse !== null) {
|
|
264
|
+
const { status } = options.mockResponse;
|
|
265
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
266
|
+
throw new Error(`Invalid mock response status: ${status}. Must be an integer 100-599.`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return this.request("PATCH", `/endpoints/${slug}`, options);
|
|
270
|
+
},
|
|
61
271
|
delete: async (slug) => {
|
|
62
272
|
validatePathSegment(slug, "slug");
|
|
63
273
|
await this.request("DELETE", `/endpoints/${slug}`);
|
|
274
|
+
},
|
|
275
|
+
send: async (slug, options = {}) => {
|
|
276
|
+
validatePathSegment(slug, "slug");
|
|
277
|
+
const rawMethod = (options.method ?? "POST").toUpperCase();
|
|
278
|
+
if (!ALLOWED_METHODS.has(rawMethod)) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`Invalid HTTP method: "${options.method}". Must be one of: ${[...ALLOWED_METHODS].join(", ")}`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const { headers = {}, body } = options;
|
|
284
|
+
const method = rawMethod;
|
|
285
|
+
const url = `${this.webhookUrl}/w/${slug}`;
|
|
286
|
+
const fetchHeaders = { ...headers };
|
|
287
|
+
const hasContentType = Object.keys(fetchHeaders).some(
|
|
288
|
+
(k) => k.toLowerCase() === "content-type"
|
|
289
|
+
);
|
|
290
|
+
if (body !== void 0 && !hasContentType) {
|
|
291
|
+
fetchHeaders["Content-Type"] = "application/json";
|
|
292
|
+
}
|
|
293
|
+
return fetch(url, {
|
|
294
|
+
method,
|
|
295
|
+
headers: fetchHeaders,
|
|
296
|
+
body: body !== void 0 ? typeof body === "string" ? body : JSON.stringify(body) : void 0,
|
|
297
|
+
signal: AbortSignal.timeout(this.timeout)
|
|
298
|
+
});
|
|
64
299
|
}
|
|
65
300
|
};
|
|
66
301
|
this.requests = {
|
|
@@ -93,13 +328,15 @@ var WebhooksCC = class {
|
|
|
93
328
|
*/
|
|
94
329
|
waitFor: async (endpointSlug, options = {}) => {
|
|
95
330
|
validatePathSegment(endpointSlug, "endpointSlug");
|
|
96
|
-
const
|
|
331
|
+
const timeout = parseDuration(options.timeout ?? 3e4);
|
|
332
|
+
const rawPollInterval = parseDuration(options.pollInterval ?? 500);
|
|
333
|
+
const { match } = options;
|
|
97
334
|
const safePollInterval = Math.max(
|
|
98
335
|
MIN_POLL_INTERVAL,
|
|
99
|
-
Math.min(MAX_POLL_INTERVAL,
|
|
336
|
+
Math.min(MAX_POLL_INTERVAL, rawPollInterval)
|
|
100
337
|
);
|
|
101
338
|
const start = Date.now();
|
|
102
|
-
let lastChecked =
|
|
339
|
+
let lastChecked = start - 5 * 60 * 1e3;
|
|
103
340
|
const MAX_ITERATIONS = 1e4;
|
|
104
341
|
let iterations = 0;
|
|
105
342
|
while (Date.now() - start < timeout && iterations < MAX_ITERATIONS) {
|
|
@@ -116,38 +353,215 @@ var WebhooksCC = class {
|
|
|
116
353
|
return matched;
|
|
117
354
|
}
|
|
118
355
|
} catch (error) {
|
|
119
|
-
if (error instanceof
|
|
120
|
-
if (error
|
|
121
|
-
throw
|
|
122
|
-
}
|
|
123
|
-
if (error.statusCode === 403) {
|
|
124
|
-
throw new Error("Access denied: insufficient permissions for this endpoint");
|
|
356
|
+
if (error instanceof WebhooksCCError) {
|
|
357
|
+
if (error instanceof UnauthorizedError) {
|
|
358
|
+
throw error;
|
|
125
359
|
}
|
|
126
|
-
if (error
|
|
127
|
-
throw
|
|
360
|
+
if (error instanceof NotFoundError) {
|
|
361
|
+
throw error;
|
|
128
362
|
}
|
|
129
|
-
if (error.statusCode < 500) {
|
|
363
|
+
if (error.statusCode < 500 && !(error instanceof RateLimitError)) {
|
|
130
364
|
throw error;
|
|
131
365
|
}
|
|
132
366
|
}
|
|
133
367
|
}
|
|
134
368
|
await sleep(safePollInterval);
|
|
135
369
|
}
|
|
136
|
-
|
|
137
|
-
|
|
370
|
+
throw new TimeoutError(timeout);
|
|
371
|
+
},
|
|
372
|
+
/**
|
|
373
|
+
* Replay a captured request to a target URL.
|
|
374
|
+
*
|
|
375
|
+
* Fetches the original request by ID and re-sends it to the specified URL
|
|
376
|
+
* with the original method, headers, and body. Hop-by-hop headers are stripped.
|
|
377
|
+
*/
|
|
378
|
+
replay: async (requestId, targetUrl) => {
|
|
379
|
+
validatePathSegment(requestId, "requestId");
|
|
380
|
+
let parsed;
|
|
381
|
+
try {
|
|
382
|
+
parsed = new URL(targetUrl);
|
|
383
|
+
} catch {
|
|
384
|
+
throw new Error(`Invalid targetUrl: "${targetUrl}" is not a valid URL`);
|
|
385
|
+
}
|
|
386
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
387
|
+
throw new Error(`Invalid targetUrl: only http and https protocols are supported`);
|
|
388
|
+
}
|
|
389
|
+
const captured = await this.requests.get(requestId);
|
|
390
|
+
const headers = {};
|
|
391
|
+
for (const [key, val] of Object.entries(captured.headers)) {
|
|
392
|
+
const lower = key.toLowerCase();
|
|
393
|
+
if (!HOP_BY_HOP_HEADERS.has(lower) && !SENSITIVE_HEADERS.has(lower)) {
|
|
394
|
+
headers[key] = val;
|
|
395
|
+
}
|
|
138
396
|
}
|
|
139
|
-
|
|
397
|
+
const upperMethod = captured.method.toUpperCase();
|
|
398
|
+
const body = upperMethod === "GET" || upperMethod === "HEAD" ? void 0 : captured.body ?? void 0;
|
|
399
|
+
return fetch(targetUrl, {
|
|
400
|
+
method: captured.method,
|
|
401
|
+
headers,
|
|
402
|
+
body,
|
|
403
|
+
signal: AbortSignal.timeout(this.timeout)
|
|
404
|
+
});
|
|
405
|
+
},
|
|
406
|
+
/**
|
|
407
|
+
* Stream incoming requests via SSE as an async iterator.
|
|
408
|
+
*
|
|
409
|
+
* Connects to the SSE endpoint and yields Request objects as they arrive.
|
|
410
|
+
* The connection is closed when the iterator is broken, the signal is aborted,
|
|
411
|
+
* or the timeout expires.
|
|
412
|
+
*
|
|
413
|
+
* No automatic reconnection — if the connection drops, the iterator ends.
|
|
414
|
+
*/
|
|
415
|
+
subscribe: (slug, options = {}) => {
|
|
416
|
+
validatePathSegment(slug, "slug");
|
|
417
|
+
const { signal, timeout } = options;
|
|
418
|
+
const baseUrl = this.baseUrl;
|
|
419
|
+
const apiKey = this.apiKey;
|
|
420
|
+
const timeoutMs = timeout !== void 0 ? parseDuration(timeout) : void 0;
|
|
421
|
+
return {
|
|
422
|
+
[Symbol.asyncIterator]() {
|
|
423
|
+
const controller = new AbortController();
|
|
424
|
+
let timeoutId;
|
|
425
|
+
let iterator = null;
|
|
426
|
+
let started = false;
|
|
427
|
+
const onAbort = () => controller.abort();
|
|
428
|
+
if (signal) {
|
|
429
|
+
if (signal.aborted) {
|
|
430
|
+
controller.abort();
|
|
431
|
+
} else {
|
|
432
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (timeoutMs !== void 0) {
|
|
436
|
+
timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
437
|
+
}
|
|
438
|
+
const cleanup = () => {
|
|
439
|
+
if (timeoutId !== void 0) clearTimeout(timeoutId);
|
|
440
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
441
|
+
};
|
|
442
|
+
const start = async () => {
|
|
443
|
+
const url = `${baseUrl}/api/stream/${slug}`;
|
|
444
|
+
const connectController = new AbortController();
|
|
445
|
+
const connectTimeout = setTimeout(() => connectController.abort(), 3e4);
|
|
446
|
+
controller.signal.addEventListener("abort", () => connectController.abort(), {
|
|
447
|
+
once: true
|
|
448
|
+
});
|
|
449
|
+
let response;
|
|
450
|
+
try {
|
|
451
|
+
response = await fetch(url, {
|
|
452
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
453
|
+
signal: connectController.signal
|
|
454
|
+
});
|
|
455
|
+
} finally {
|
|
456
|
+
clearTimeout(connectTimeout);
|
|
457
|
+
}
|
|
458
|
+
if (!response.ok) {
|
|
459
|
+
cleanup();
|
|
460
|
+
const text = await response.text();
|
|
461
|
+
throw mapStatusToError(response.status, text, response);
|
|
462
|
+
}
|
|
463
|
+
if (!response.body) {
|
|
464
|
+
cleanup();
|
|
465
|
+
throw new Error("SSE response has no body");
|
|
466
|
+
}
|
|
467
|
+
controller.signal.addEventListener(
|
|
468
|
+
"abort",
|
|
469
|
+
() => {
|
|
470
|
+
response.body?.cancel();
|
|
471
|
+
},
|
|
472
|
+
{ once: true }
|
|
473
|
+
);
|
|
474
|
+
return parseSSE(response.body);
|
|
475
|
+
};
|
|
476
|
+
return {
|
|
477
|
+
[Symbol.asyncIterator]() {
|
|
478
|
+
return this;
|
|
479
|
+
},
|
|
480
|
+
async next() {
|
|
481
|
+
try {
|
|
482
|
+
if (!started) {
|
|
483
|
+
started = true;
|
|
484
|
+
iterator = await start();
|
|
485
|
+
}
|
|
486
|
+
while (iterator) {
|
|
487
|
+
const { done, value } = await iterator.next();
|
|
488
|
+
if (done) {
|
|
489
|
+
cleanup();
|
|
490
|
+
return { done: true, value: void 0 };
|
|
491
|
+
}
|
|
492
|
+
if (value.event === "request") {
|
|
493
|
+
try {
|
|
494
|
+
const data = JSON.parse(value.data);
|
|
495
|
+
if (!data.endpointId || !data.method || !data.headers || !data.receivedAt) {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const req = {
|
|
499
|
+
id: data._id ?? data.id,
|
|
500
|
+
endpointId: data.endpointId,
|
|
501
|
+
method: data.method,
|
|
502
|
+
path: data.path ?? "/",
|
|
503
|
+
headers: data.headers,
|
|
504
|
+
body: data.body ?? void 0,
|
|
505
|
+
queryParams: data.queryParams ?? {},
|
|
506
|
+
contentType: data.contentType ?? void 0,
|
|
507
|
+
ip: data.ip ?? "unknown",
|
|
508
|
+
size: data.size ?? 0,
|
|
509
|
+
receivedAt: data.receivedAt
|
|
510
|
+
};
|
|
511
|
+
return { done: false, value: req };
|
|
512
|
+
} catch {
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (value.event === "timeout" || value.event === "endpoint_deleted") {
|
|
517
|
+
cleanup();
|
|
518
|
+
return { done: true, value: void 0 };
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
cleanup();
|
|
522
|
+
return { done: true, value: void 0 };
|
|
523
|
+
} catch (error) {
|
|
524
|
+
cleanup();
|
|
525
|
+
controller.abort();
|
|
526
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
527
|
+
return { done: true, value: void 0 };
|
|
528
|
+
}
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
async return() {
|
|
533
|
+
cleanup();
|
|
534
|
+
controller.abort();
|
|
535
|
+
if (iterator) {
|
|
536
|
+
await iterator.return(void 0);
|
|
537
|
+
}
|
|
538
|
+
return { done: true, value: void 0 };
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
};
|
|
140
543
|
}
|
|
141
544
|
};
|
|
545
|
+
if (!options.apiKey || typeof options.apiKey !== "string") {
|
|
546
|
+
throw new Error("Missing or invalid apiKey. Get one at https://webhooks.cc/account");
|
|
547
|
+
}
|
|
142
548
|
this.apiKey = options.apiKey;
|
|
143
|
-
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
549
|
+
this.baseUrl = stripTrailingSlashes(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
550
|
+
this.webhookUrl = stripTrailingSlashes(options.webhookUrl ?? DEFAULT_WEBHOOK_URL);
|
|
144
551
|
this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
552
|
+
this.hooks = options.hooks ?? {};
|
|
145
553
|
}
|
|
146
554
|
async request(method, path, body) {
|
|
555
|
+
const url = `${this.baseUrl}/api${path}`;
|
|
147
556
|
const controller = new AbortController();
|
|
148
557
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
558
|
+
const start = Date.now();
|
|
559
|
+
try {
|
|
560
|
+
this.hooks.onRequest?.({ method, url });
|
|
561
|
+
} catch {
|
|
562
|
+
}
|
|
149
563
|
try {
|
|
150
|
-
const response = await fetch(
|
|
564
|
+
const response = await fetch(url, {
|
|
151
565
|
method,
|
|
152
566
|
headers: {
|
|
153
567
|
Authorization: `Bearer ${this.apiKey}`,
|
|
@@ -156,10 +570,20 @@ var WebhooksCC = class {
|
|
|
156
570
|
body: body ? JSON.stringify(body) : void 0,
|
|
157
571
|
signal: controller.signal
|
|
158
572
|
});
|
|
573
|
+
const durationMs = Date.now() - start;
|
|
159
574
|
if (!response.ok) {
|
|
160
|
-
const
|
|
161
|
-
const sanitizedError =
|
|
162
|
-
|
|
575
|
+
const errorText = await response.text();
|
|
576
|
+
const sanitizedError = errorText.length > 200 ? errorText.slice(0, 200) + "..." : errorText;
|
|
577
|
+
const error = mapStatusToError(response.status, sanitizedError, response);
|
|
578
|
+
try {
|
|
579
|
+
this.hooks.onError?.({ method, url, error, durationMs });
|
|
580
|
+
} catch {
|
|
581
|
+
}
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
this.hooks.onResponse?.({ method, url, status: response.status, durationMs });
|
|
586
|
+
} catch {
|
|
163
587
|
}
|
|
164
588
|
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
165
589
|
return void 0;
|
|
@@ -171,19 +595,197 @@ var WebhooksCC = class {
|
|
|
171
595
|
return response.json();
|
|
172
596
|
} catch (error) {
|
|
173
597
|
if (error instanceof Error && error.name === "AbortError") {
|
|
174
|
-
|
|
598
|
+
const timeoutError = new TimeoutError(this.timeout);
|
|
599
|
+
try {
|
|
600
|
+
this.hooks.onError?.({
|
|
601
|
+
method,
|
|
602
|
+
url,
|
|
603
|
+
error: timeoutError,
|
|
604
|
+
durationMs: Date.now() - start
|
|
605
|
+
});
|
|
606
|
+
} catch {
|
|
607
|
+
}
|
|
608
|
+
throw timeoutError;
|
|
175
609
|
}
|
|
176
610
|
throw error;
|
|
177
611
|
} finally {
|
|
178
612
|
clearTimeout(timeoutId);
|
|
179
613
|
}
|
|
180
614
|
}
|
|
615
|
+
/** Returns a static description of all SDK operations (no API call). */
|
|
616
|
+
describe() {
|
|
617
|
+
return {
|
|
618
|
+
version: SDK_VERSION,
|
|
619
|
+
endpoints: {
|
|
620
|
+
create: {
|
|
621
|
+
description: "Create a webhook endpoint",
|
|
622
|
+
params: { name: "string?" }
|
|
623
|
+
},
|
|
624
|
+
list: {
|
|
625
|
+
description: "List all endpoints",
|
|
626
|
+
params: {}
|
|
627
|
+
},
|
|
628
|
+
get: {
|
|
629
|
+
description: "Get endpoint by slug",
|
|
630
|
+
params: { slug: "string" }
|
|
631
|
+
},
|
|
632
|
+
update: {
|
|
633
|
+
description: "Update endpoint settings",
|
|
634
|
+
params: { slug: "string", name: "string?", mockResponse: "object?" }
|
|
635
|
+
},
|
|
636
|
+
delete: {
|
|
637
|
+
description: "Delete endpoint and its requests",
|
|
638
|
+
params: { slug: "string" }
|
|
639
|
+
},
|
|
640
|
+
send: {
|
|
641
|
+
description: "Send a test webhook to endpoint",
|
|
642
|
+
params: { slug: "string", method: "string?", headers: "object?", body: "unknown?" }
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
requests: {
|
|
646
|
+
list: {
|
|
647
|
+
description: "List captured requests",
|
|
648
|
+
params: { endpointSlug: "string", limit: "number?", since: "number?" }
|
|
649
|
+
},
|
|
650
|
+
get: {
|
|
651
|
+
description: "Get request by ID",
|
|
652
|
+
params: { requestId: "string" }
|
|
653
|
+
},
|
|
654
|
+
waitFor: {
|
|
655
|
+
description: "Poll until a matching request arrives",
|
|
656
|
+
params: {
|
|
657
|
+
endpointSlug: "string",
|
|
658
|
+
timeout: "number|string?",
|
|
659
|
+
match: "function?"
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
subscribe: {
|
|
663
|
+
description: "Stream requests via SSE",
|
|
664
|
+
params: { slug: "string", signal: "AbortSignal?", timeout: "number|string?" }
|
|
665
|
+
},
|
|
666
|
+
replay: {
|
|
667
|
+
description: "Replay a captured request to a URL",
|
|
668
|
+
params: { requestId: "string", targetUrl: "string" }
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
}
|
|
181
673
|
};
|
|
182
674
|
function sleep(ms) {
|
|
183
675
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
184
676
|
}
|
|
677
|
+
function stripTrailingSlashes(url) {
|
|
678
|
+
let i = url.length;
|
|
679
|
+
while (i > 0 && url[i - 1] === "/") i--;
|
|
680
|
+
return i === url.length ? url : url.slice(0, i);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// src/helpers.ts
|
|
684
|
+
function parseJsonBody(request) {
|
|
685
|
+
if (!request.body) return void 0;
|
|
686
|
+
try {
|
|
687
|
+
return JSON.parse(request.body);
|
|
688
|
+
} catch {
|
|
689
|
+
return void 0;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function isStripeWebhook(request) {
|
|
693
|
+
return Object.keys(request.headers).some((k) => k.toLowerCase() === "stripe-signature");
|
|
694
|
+
}
|
|
695
|
+
function isGitHubWebhook(request) {
|
|
696
|
+
return Object.keys(request.headers).some((k) => k.toLowerCase() === "x-github-event");
|
|
697
|
+
}
|
|
698
|
+
function matchJsonField(field, value) {
|
|
699
|
+
return (request) => {
|
|
700
|
+
const body = parseJsonBody(request);
|
|
701
|
+
if (typeof body !== "object" || body === null) return false;
|
|
702
|
+
if (!Object.prototype.hasOwnProperty.call(body, field)) return false;
|
|
703
|
+
return body[field] === value;
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function isShopifyWebhook(request) {
|
|
707
|
+
return Object.keys(request.headers).some((k) => k.toLowerCase() === "x-shopify-hmac-sha256");
|
|
708
|
+
}
|
|
709
|
+
function isSlackWebhook(request) {
|
|
710
|
+
return Object.keys(request.headers).some((k) => k.toLowerCase() === "x-slack-signature");
|
|
711
|
+
}
|
|
712
|
+
function isTwilioWebhook(request) {
|
|
713
|
+
return Object.keys(request.headers).some((k) => k.toLowerCase() === "x-twilio-signature");
|
|
714
|
+
}
|
|
715
|
+
function isPaddleWebhook(request) {
|
|
716
|
+
return Object.keys(request.headers).some((k) => k.toLowerCase() === "paddle-signature");
|
|
717
|
+
}
|
|
718
|
+
function isLinearWebhook(request) {
|
|
719
|
+
return Object.keys(request.headers).some((k) => k.toLowerCase() === "linear-signature");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// src/matchers.ts
|
|
723
|
+
function matchMethod(method) {
|
|
724
|
+
const upper = method.toUpperCase();
|
|
725
|
+
return (request) => request.method.toUpperCase() === upper;
|
|
726
|
+
}
|
|
727
|
+
function matchHeader(name, value) {
|
|
728
|
+
const lowerName = name.toLowerCase();
|
|
729
|
+
return (request) => {
|
|
730
|
+
const entry = Object.entries(request.headers).find(([k]) => k.toLowerCase() === lowerName);
|
|
731
|
+
if (!entry) return false;
|
|
732
|
+
if (value === void 0) return true;
|
|
733
|
+
return entry[1] === value;
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
function matchBodyPath(path, value) {
|
|
737
|
+
const keys = path.split(".");
|
|
738
|
+
return (request) => {
|
|
739
|
+
const body = parseJsonBody(request);
|
|
740
|
+
if (typeof body !== "object" || body === null) return false;
|
|
741
|
+
let current = body;
|
|
742
|
+
for (const key of keys) {
|
|
743
|
+
if (current === null || current === void 0) return false;
|
|
744
|
+
if (Array.isArray(current)) {
|
|
745
|
+
const idx = Number(key);
|
|
746
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= current.length) return false;
|
|
747
|
+
current = current[idx];
|
|
748
|
+
} else if (typeof current === "object") {
|
|
749
|
+
if (!Object.prototype.hasOwnProperty.call(current, key)) return false;
|
|
750
|
+
current = current[key];
|
|
751
|
+
} else {
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return current === value;
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
function matchAll(first, ...rest) {
|
|
759
|
+
const matchers = [first, ...rest];
|
|
760
|
+
return (request) => matchers.every((m) => m(request));
|
|
761
|
+
}
|
|
762
|
+
function matchAny(first, ...rest) {
|
|
763
|
+
const matchers = [first, ...rest];
|
|
764
|
+
return (request) => matchers.some((m) => m(request));
|
|
765
|
+
}
|
|
185
766
|
// Annotate the CommonJS export names for ESM import in node:
|
|
186
767
|
0 && (module.exports = {
|
|
187
768
|
ApiError,
|
|
188
|
-
|
|
769
|
+
NotFoundError,
|
|
770
|
+
RateLimitError,
|
|
771
|
+
TimeoutError,
|
|
772
|
+
UnauthorizedError,
|
|
773
|
+
WebhooksCC,
|
|
774
|
+
WebhooksCCError,
|
|
775
|
+
isGitHubWebhook,
|
|
776
|
+
isLinearWebhook,
|
|
777
|
+
isPaddleWebhook,
|
|
778
|
+
isShopifyWebhook,
|
|
779
|
+
isSlackWebhook,
|
|
780
|
+
isStripeWebhook,
|
|
781
|
+
isTwilioWebhook,
|
|
782
|
+
matchAll,
|
|
783
|
+
matchAny,
|
|
784
|
+
matchBodyPath,
|
|
785
|
+
matchHeader,
|
|
786
|
+
matchJsonField,
|
|
787
|
+
matchMethod,
|
|
788
|
+
parseDuration,
|
|
789
|
+
parseJsonBody,
|
|
790
|
+
parseSSE
|
|
189
791
|
});
|