@webhooks-cc/sdk 0.2.0 → 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/dist/index.js CHANGED
@@ -28,9 +28,21 @@ __export(index_exports, {
28
28
  WebhooksCC: () => WebhooksCC,
29
29
  WebhooksCCError: () => WebhooksCCError,
30
30
  isGitHubWebhook: () => isGitHubWebhook,
31
+ isLinearWebhook: () => isLinearWebhook,
32
+ isPaddleWebhook: () => isPaddleWebhook,
33
+ isShopifyWebhook: () => isShopifyWebhook,
34
+ isSlackWebhook: () => isSlackWebhook,
31
35
  isStripeWebhook: () => isStripeWebhook,
36
+ isTwilioWebhook: () => isTwilioWebhook,
37
+ matchAll: () => matchAll,
38
+ matchAny: () => matchAny,
39
+ matchBodyPath: () => matchBodyPath,
40
+ matchHeader: () => matchHeader,
32
41
  matchJsonField: () => matchJsonField,
33
- parseJsonBody: () => parseJsonBody
42
+ matchMethod: () => matchMethod,
43
+ parseDuration: () => parseDuration,
44
+ parseJsonBody: () => parseJsonBody,
45
+ parseSSE: () => parseSSE
34
46
  });
35
47
  module.exports = __toCommonJS(index_exports);
36
48
 
@@ -70,18 +82,148 @@ var RateLimitError = class extends WebhooksCCError {
70
82
  }
71
83
  };
72
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
+
73
196
  // src/client.ts
74
197
  var DEFAULT_BASE_URL = "https://webhooks.cc";
198
+ var DEFAULT_WEBHOOK_URL = "https://go.webhooks.cc";
75
199
  var DEFAULT_TIMEOUT = 3e4;
200
+ var SDK_VERSION = "0.3.0";
76
201
  var MIN_POLL_INTERVAL = 10;
77
202
  var MAX_POLL_INTERVAL = 6e4;
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"]);
78
215
  var ApiError = WebhooksCCError;
79
216
  function mapStatusToError(status, message, response) {
217
+ const isGeneric = message.length < 30;
80
218
  switch (status) {
81
- case 401:
82
- return new UnauthorizedError(message);
83
- case 404:
84
- return new NotFoundError(message);
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
+ }
85
227
  case 429: {
86
228
  const retryAfterHeader = response.headers.get("retry-after");
87
229
  let retryAfter;
@@ -116,9 +258,44 @@ var WebhooksCC = class {
116
258
  validatePathSegment(slug, "slug");
117
259
  return this.request("GET", `/endpoints/${slug}`);
118
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
+ },
119
271
  delete: async (slug) => {
120
272
  validatePathSegment(slug, "slug");
121
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
+ });
122
299
  }
123
300
  };
124
301
  this.requests = {
@@ -151,13 +328,15 @@ var WebhooksCC = class {
151
328
  */
152
329
  waitFor: async (endpointSlug, options = {}) => {
153
330
  validatePathSegment(endpointSlug, "endpointSlug");
154
- const { timeout = 3e4, pollInterval = 500, match } = options;
331
+ const timeout = parseDuration(options.timeout ?? 3e4);
332
+ const rawPollInterval = parseDuration(options.pollInterval ?? 500);
333
+ const { match } = options;
155
334
  const safePollInterval = Math.max(
156
335
  MIN_POLL_INTERVAL,
157
- Math.min(MAX_POLL_INTERVAL, pollInterval)
336
+ Math.min(MAX_POLL_INTERVAL, rawPollInterval)
158
337
  );
159
338
  const start = Date.now();
160
- let lastChecked = 0;
339
+ let lastChecked = start - 5 * 60 * 1e3;
161
340
  const MAX_ITERATIONS = 1e4;
162
341
  let iterations = 0;
163
342
  while (Date.now() - start < timeout && iterations < MAX_ITERATIONS) {
@@ -189,10 +368,186 @@ var WebhooksCC = class {
189
368
  await sleep(safePollInterval);
190
369
  }
191
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
+ }
396
+ }
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
+ };
192
543
  }
193
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
+ }
194
548
  this.apiKey = options.apiKey;
195
- 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);
196
551
  this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
197
552
  this.hooks = options.hooks ?? {};
198
553
  }
@@ -257,10 +612,73 @@ var WebhooksCC = class {
257
612
  clearTimeout(timeoutId);
258
613
  }
259
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
+ }
260
673
  };
261
674
  function sleep(ms) {
262
675
  return new Promise((resolve) => setTimeout(resolve, ms));
263
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
+ }
264
682
 
265
683
  // src/helpers.ts
266
684
  function parseJsonBody(request) {
@@ -285,6 +703,66 @@ function matchJsonField(field, value) {
285
703
  return body[field] === value;
286
704
  };
287
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
+ }
288
766
  // Annotate the CommonJS export names for ESM import in node:
289
767
  0 && (module.exports = {
290
768
  ApiError,
@@ -295,7 +773,19 @@ function matchJsonField(field, value) {
295
773
  WebhooksCC,
296
774
  WebhooksCCError,
297
775
  isGitHubWebhook,
776
+ isLinearWebhook,
777
+ isPaddleWebhook,
778
+ isShopifyWebhook,
779
+ isSlackWebhook,
298
780
  isStripeWebhook,
781
+ isTwilioWebhook,
782
+ matchAll,
783
+ matchAny,
784
+ matchBodyPath,
785
+ matchHeader,
299
786
  matchJsonField,
300
- parseJsonBody
787
+ matchMethod,
788
+ parseDuration,
789
+ parseJsonBody,
790
+ parseSSE
301
791
  });