@better-webhook/cli 3.3.0 → 3.4.1

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.
@@ -0,0 +1,124 @@
1
+ import express from "express";
2
+ import { createServer } from "http";
3
+ import { WebSocketServer } from "ws";
4
+ import path from "path";
5
+ import { existsSync } from "fs";
6
+ import { fileURLToPath } from "url";
7
+ import { createDashboardApiRouter, } from "./dashboard-api.js";
8
+ import { CaptureServer } from "./capture-server.js";
9
+ import { ReplayEngine } from "./replay-engine.js";
10
+ import { TemplateManager } from "./template-manager.js";
11
+ function resolveDashboardDistDir(runtimeDir) {
12
+ const candidates = [
13
+ path.resolve(runtimeDir, "dashboard"),
14
+ path.resolve(runtimeDir, "..", "dashboard"),
15
+ path.resolve(runtimeDir, "..", "..", "dist", "dashboard"),
16
+ path.resolve(runtimeDir, "..", "..", "..", "dashboard", "dist"),
17
+ ];
18
+ for (const distDir of candidates) {
19
+ const indexHtml = path.join(distDir, "index.html");
20
+ if (existsSync(indexHtml)) {
21
+ return { distDir, indexHtml };
22
+ }
23
+ }
24
+ const details = candidates.map((p) => `- ${p}`).join("\n");
25
+ throw new Error(`Dashboard UI build output not found.\n` +
26
+ `Looked in:\n${details}\n\n` +
27
+ `Build it with:\n` +
28
+ `- pnpm --filter @better-webhook/dashboard build\n` +
29
+ `- pnpm --filter @better-webhook/cli build\n`);
30
+ }
31
+ export async function startDashboardServer(options = {}) {
32
+ const app = express();
33
+ app.get("/health", (_req, res) => {
34
+ res.json({ ok: true });
35
+ });
36
+ const clients = new Set();
37
+ const broadcast = (message) => {
38
+ const data = JSON.stringify(message);
39
+ for (const client of clients) {
40
+ if (client.readyState === 1) {
41
+ client.send(data);
42
+ }
43
+ }
44
+ };
45
+ app.use("/api", createDashboardApiRouter({
46
+ capturesDir: options.capturesDir,
47
+ templatesBaseDir: options.templatesBaseDir,
48
+ broadcast,
49
+ }));
50
+ const host = options.host || "localhost";
51
+ const port = options.port ?? 4000;
52
+ const runtimeDir = typeof __dirname !== "undefined"
53
+ ?
54
+ __dirname
55
+ : path.dirname(fileURLToPath(import.meta.url));
56
+ const { distDir: dashboardDistDir, indexHtml: dashboardIndexHtml } = resolveDashboardDistDir(runtimeDir);
57
+ app.use(express.static(dashboardDistDir));
58
+ app.get("*", (req, res, next) => {
59
+ if (req.path.startsWith("/api") || req.path === "/health")
60
+ return next();
61
+ res.sendFile(dashboardIndexHtml, (err) => {
62
+ if (err)
63
+ next();
64
+ });
65
+ });
66
+ const server = createServer(app);
67
+ const wss = new WebSocketServer({ server, path: "/ws" });
68
+ wss.on("connection", async (ws) => {
69
+ clients.add(ws);
70
+ ws.on("close", () => clients.delete(ws));
71
+ ws.on("error", () => clients.delete(ws));
72
+ const replayEngine = new ReplayEngine(options.capturesDir);
73
+ const templateManager = new TemplateManager(options.templatesBaseDir);
74
+ const captures = replayEngine.listCaptures(200);
75
+ ws.send(JSON.stringify({
76
+ type: "captures_updated",
77
+ payload: { captures, count: captures.length },
78
+ }));
79
+ const local = templateManager.listLocalTemplates();
80
+ let remote = [];
81
+ try {
82
+ const index = await templateManager.fetchRemoteIndex(true);
83
+ const localIds = new Set(local.map((t) => t.id));
84
+ remote = index.templates.map((metadata) => ({
85
+ metadata,
86
+ isDownloaded: localIds.has(metadata.id),
87
+ }));
88
+ }
89
+ catch {
90
+ remote = [];
91
+ }
92
+ ws.send(JSON.stringify({
93
+ type: "templates_updated",
94
+ payload: { local, remote },
95
+ }));
96
+ });
97
+ await new Promise((resolve, reject) => {
98
+ server.listen(port, host, () => resolve());
99
+ server.on("error", reject);
100
+ });
101
+ const url = `http://${host}:${port}`;
102
+ let capture;
103
+ const shouldStartCapture = options.startCapture !== false;
104
+ if (shouldStartCapture) {
105
+ const captureHost = options.captureHost || "0.0.0.0";
106
+ const capturePort = options.capturePort ?? 3001;
107
+ const captureServer = new CaptureServer({
108
+ capturesDir: options.capturesDir,
109
+ enableWebSocket: false,
110
+ onCapture: ({ file, capture }) => {
111
+ broadcast({
112
+ type: "capture",
113
+ payload: { file, capture },
114
+ });
115
+ },
116
+ });
117
+ const actualPort = await captureServer.start(capturePort, captureHost);
118
+ capture = {
119
+ server: captureServer,
120
+ url: `http://${captureHost === "0.0.0.0" ? "localhost" : captureHost}:${actualPort}`,
121
+ };
122
+ }
123
+ return { app, server, url, capture };
124
+ }
@@ -0,0 +1,11 @@
1
+ import type { WebhookExecutionOptions, WebhookExecutionResult, HeaderEntry, WebhookTemplate } from "../types/index.js";
2
+ export declare function executeWebhook(options: WebhookExecutionOptions): Promise<WebhookExecutionResult>;
3
+ export declare function executeTemplate(template: WebhookTemplate, options?: {
4
+ url?: string;
5
+ secret?: string;
6
+ headers?: HeaderEntry[];
7
+ }): Promise<WebhookExecutionResult>;
8
+ export declare class ExecutionError extends Error {
9
+ duration: number;
10
+ constructor(message: string, duration: number);
11
+ }
@@ -0,0 +1,130 @@
1
+ import { request } from "undici";
2
+ import { generateSignature, getProviderHeaders } from "./signature.js";
3
+ export async function executeWebhook(options) {
4
+ const startTime = Date.now();
5
+ let bodyStr;
6
+ if (options.body !== undefined) {
7
+ bodyStr =
8
+ typeof options.body === "string"
9
+ ? options.body
10
+ : JSON.stringify(options.body);
11
+ }
12
+ const headers = {};
13
+ if (options.provider) {
14
+ const providerHeaders = getProviderHeaders(options.provider);
15
+ for (const h of providerHeaders) {
16
+ headers[h.key] = h.value;
17
+ }
18
+ }
19
+ if (options.headers) {
20
+ for (const h of options.headers) {
21
+ headers[h.key] = h.value;
22
+ }
23
+ }
24
+ if (options.secret && options.provider && bodyStr) {
25
+ const sig = generateSignature(options.provider, bodyStr, options.secret, {
26
+ url: options.url,
27
+ });
28
+ if (sig) {
29
+ headers[sig.header] = sig.value;
30
+ }
31
+ }
32
+ if (!headers["Content-Type"] && !headers["content-type"]) {
33
+ headers["Content-Type"] = "application/json";
34
+ }
35
+ try {
36
+ const response = await request(options.url, {
37
+ method: options.method || "POST",
38
+ headers,
39
+ body: bodyStr,
40
+ headersTimeout: options.timeout || 30000,
41
+ bodyTimeout: options.timeout || 30000,
42
+ });
43
+ const bodyText = await response.body.text();
44
+ const duration = Date.now() - startTime;
45
+ const responseHeaders = {};
46
+ for (const [key, value] of Object.entries(response.headers)) {
47
+ if (value !== undefined) {
48
+ responseHeaders[key] = value;
49
+ }
50
+ }
51
+ let json;
52
+ try {
53
+ json = JSON.parse(bodyText);
54
+ }
55
+ catch {
56
+ }
57
+ return {
58
+ status: response.statusCode,
59
+ statusText: getStatusText(response.statusCode),
60
+ headers: responseHeaders,
61
+ body: json ?? bodyText,
62
+ bodyText,
63
+ json,
64
+ duration,
65
+ };
66
+ }
67
+ catch (error) {
68
+ const duration = Date.now() - startTime;
69
+ throw new ExecutionError(error.message, duration);
70
+ }
71
+ }
72
+ export async function executeTemplate(template, options = {}) {
73
+ const targetUrl = options.url || template.url;
74
+ if (!targetUrl) {
75
+ throw new Error("No target URL specified. Use --url or set url in template.");
76
+ }
77
+ const mergedHeaders = [...(template.headers || [])];
78
+ if (options.headers) {
79
+ for (const h of options.headers) {
80
+ const existingIdx = mergedHeaders.findIndex((mh) => mh.key.toLowerCase() === h.key.toLowerCase());
81
+ if (existingIdx >= 0) {
82
+ mergedHeaders[existingIdx] = h;
83
+ }
84
+ else {
85
+ mergedHeaders.push(h);
86
+ }
87
+ }
88
+ }
89
+ return executeWebhook({
90
+ url: targetUrl,
91
+ method: template.method,
92
+ headers: mergedHeaders,
93
+ body: template.body,
94
+ secret: options.secret,
95
+ provider: template.provider,
96
+ });
97
+ }
98
+ export class ExecutionError extends Error {
99
+ duration;
100
+ constructor(message, duration) {
101
+ super(message);
102
+ this.name = "ExecutionError";
103
+ this.duration = duration;
104
+ }
105
+ }
106
+ function getStatusText(code) {
107
+ const statusTexts = {
108
+ 200: "OK",
109
+ 201: "Created",
110
+ 202: "Accepted",
111
+ 204: "No Content",
112
+ 301: "Moved Permanently",
113
+ 302: "Found",
114
+ 304: "Not Modified",
115
+ 400: "Bad Request",
116
+ 401: "Unauthorized",
117
+ 403: "Forbidden",
118
+ 404: "Not Found",
119
+ 405: "Method Not Allowed",
120
+ 408: "Request Timeout",
121
+ 409: "Conflict",
122
+ 422: "Unprocessable Entity",
123
+ 429: "Too Many Requests",
124
+ 500: "Internal Server Error",
125
+ 502: "Bad Gateway",
126
+ 503: "Service Unavailable",
127
+ 504: "Gateway Timeout",
128
+ };
129
+ return statusTexts[code] || "Unknown";
130
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./template-manager.js";
2
+ export * from "./signature.js";
3
+ export * from "./capture-server.js";
4
+ export * from "./replay-engine.js";
5
+ export * from "./executor.js";
@@ -0,0 +1,5 @@
1
+ export * from "./template-manager.js";
2
+ export * from "./signature.js";
3
+ export * from "./capture-server.js";
4
+ export * from "./replay-engine.js";
5
+ export * from "./executor.js";
@@ -0,0 +1,18 @@
1
+ import type { CaptureFile, ReplayOptions, WebhookExecutionResult, WebhookTemplate } from "../types/index.js";
2
+ export declare class ReplayEngine {
3
+ private capturesDir;
4
+ constructor(capturesDir?: string);
5
+ getCapturesDir(): string;
6
+ listCaptures(limit?: number): CaptureFile[];
7
+ getCapture(captureId: string): CaptureFile | null;
8
+ replay(captureId: string, options: ReplayOptions): Promise<WebhookExecutionResult>;
9
+ captureToTemplate(captureId: string, options?: {
10
+ url?: string;
11
+ }): WebhookTemplate;
12
+ getCaptureSummary(captureId: string): string;
13
+ searchCaptures(query: string): CaptureFile[];
14
+ getCapturesByProvider(provider: string): CaptureFile[];
15
+ deleteCapture(captureId: string): boolean;
16
+ deleteAllCaptures(): number;
17
+ }
18
+ export declare function getReplayEngine(capturesDir?: string): ReplayEngine;
@@ -0,0 +1,208 @@
1
+ import { existsSync, readFileSync, readdirSync, unlinkSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { executeWebhook } from "./executor.js";
5
+ export class ReplayEngine {
6
+ capturesDir;
7
+ constructor(capturesDir) {
8
+ this.capturesDir =
9
+ capturesDir || join(homedir(), ".better-webhook", "captures");
10
+ }
11
+ getCapturesDir() {
12
+ return this.capturesDir;
13
+ }
14
+ listCaptures(limit = 100) {
15
+ if (!existsSync(this.capturesDir)) {
16
+ return [];
17
+ }
18
+ const files = readdirSync(this.capturesDir)
19
+ .filter((f) => f.endsWith(".json"))
20
+ .sort()
21
+ .reverse()
22
+ .slice(0, limit);
23
+ const captures = [];
24
+ for (const file of files) {
25
+ try {
26
+ const content = readFileSync(join(this.capturesDir, file), "utf-8");
27
+ const capture = JSON.parse(content);
28
+ captures.push({ file, capture });
29
+ }
30
+ catch {
31
+ }
32
+ }
33
+ return captures;
34
+ }
35
+ getCapture(captureId) {
36
+ const captures = this.listCaptures(1000);
37
+ let found = captures.find((c) => c.capture.id === captureId);
38
+ if (found)
39
+ return found;
40
+ found = captures.find((c) => c.file.includes(captureId));
41
+ if (found)
42
+ return found;
43
+ found = captures.find((c) => c.capture.id.startsWith(captureId));
44
+ return found || null;
45
+ }
46
+ async replay(captureId, options) {
47
+ const captureFile = this.getCapture(captureId);
48
+ if (!captureFile) {
49
+ throw new Error(`Capture not found: ${captureId}`);
50
+ }
51
+ const { capture } = captureFile;
52
+ const headers = [];
53
+ const skipHeaders = [
54
+ "host",
55
+ "content-length",
56
+ "connection",
57
+ "accept-encoding",
58
+ ];
59
+ for (const [key, value] of Object.entries(capture.headers)) {
60
+ if (!skipHeaders.includes(key.toLowerCase())) {
61
+ const headerValue = Array.isArray(value) ? value.join(", ") : value;
62
+ if (headerValue) {
63
+ headers.push({ key, value: headerValue });
64
+ }
65
+ }
66
+ }
67
+ if (options.headers) {
68
+ for (const h of options.headers) {
69
+ const existingIdx = headers.findIndex((eh) => eh.key.toLowerCase() === h.key.toLowerCase());
70
+ if (existingIdx >= 0) {
71
+ headers[existingIdx] = h;
72
+ }
73
+ else {
74
+ headers.push(h);
75
+ }
76
+ }
77
+ }
78
+ const body = capture.rawBody || capture.body;
79
+ return executeWebhook({
80
+ url: options.targetUrl,
81
+ method: options.method || capture.method,
82
+ headers,
83
+ body,
84
+ });
85
+ }
86
+ captureToTemplate(captureId, options) {
87
+ const captureFile = this.getCapture(captureId);
88
+ if (!captureFile) {
89
+ throw new Error(`Capture not found: ${captureId}`);
90
+ }
91
+ const { capture } = captureFile;
92
+ const skipHeaders = [
93
+ "host",
94
+ "content-length",
95
+ "connection",
96
+ "accept-encoding",
97
+ "stripe-signature",
98
+ "x-hub-signature-256",
99
+ "x-hub-signature",
100
+ "x-shopify-hmac-sha256",
101
+ "x-twilio-signature",
102
+ "x-slack-signature",
103
+ "svix-signature",
104
+ "linear-signature",
105
+ ];
106
+ const headers = [];
107
+ for (const [key, value] of Object.entries(capture.headers)) {
108
+ if (!skipHeaders.includes(key.toLowerCase())) {
109
+ const headerValue = Array.isArray(value) ? value.join(", ") : value;
110
+ if (headerValue) {
111
+ headers.push({ key, value: headerValue });
112
+ }
113
+ }
114
+ }
115
+ let body;
116
+ if (capture.body) {
117
+ body = capture.body;
118
+ }
119
+ else if (capture.rawBody) {
120
+ try {
121
+ body = JSON.parse(capture.rawBody);
122
+ }
123
+ catch {
124
+ body = capture.rawBody;
125
+ }
126
+ }
127
+ return {
128
+ url: options?.url || `http://localhost:3000${capture.path}`,
129
+ method: capture.method,
130
+ headers,
131
+ body,
132
+ provider: capture.provider,
133
+ description: `Captured ${capture.provider || "webhook"} at ${capture.timestamp}`,
134
+ };
135
+ }
136
+ getCaptureSummary(captureId) {
137
+ const captureFile = this.getCapture(captureId);
138
+ if (!captureFile) {
139
+ return "Capture not found";
140
+ }
141
+ const { capture } = captureFile;
142
+ const lines = [];
143
+ lines.push(`ID: ${capture.id}`);
144
+ lines.push(`Timestamp: ${new Date(capture.timestamp).toLocaleString()}`);
145
+ lines.push(`Method: ${capture.method}`);
146
+ lines.push(`Path: ${capture.path}`);
147
+ if (capture.provider) {
148
+ lines.push(`Provider: ${capture.provider}`);
149
+ }
150
+ lines.push(`Content-Type: ${capture.contentType || "unknown"}`);
151
+ lines.push(`Body Size: ${capture.contentLength || 0} bytes`);
152
+ const headerCount = Object.keys(capture.headers).length;
153
+ lines.push(`Headers: ${headerCount}`);
154
+ return lines.join("\n");
155
+ }
156
+ searchCaptures(query) {
157
+ const queryLower = query.toLowerCase();
158
+ const captures = this.listCaptures(1000);
159
+ return captures.filter((c) => {
160
+ const { capture } = c;
161
+ return (capture.id.toLowerCase().includes(queryLower) ||
162
+ capture.path.toLowerCase().includes(queryLower) ||
163
+ capture.method.toLowerCase().includes(queryLower) ||
164
+ capture.provider?.toLowerCase().includes(queryLower) ||
165
+ c.file.toLowerCase().includes(queryLower));
166
+ });
167
+ }
168
+ getCapturesByProvider(provider) {
169
+ const captures = this.listCaptures(1000);
170
+ return captures.filter((c) => c.capture.provider === provider);
171
+ }
172
+ deleteCapture(captureId) {
173
+ const captureFile = this.getCapture(captureId);
174
+ if (!captureFile) {
175
+ return false;
176
+ }
177
+ try {
178
+ unlinkSync(join(this.capturesDir, captureFile.file));
179
+ return true;
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ }
185
+ deleteAllCaptures() {
186
+ if (!existsSync(this.capturesDir)) {
187
+ return 0;
188
+ }
189
+ const files = readdirSync(this.capturesDir).filter((f) => f.endsWith(".json"));
190
+ let deleted = 0;
191
+ for (const file of files) {
192
+ try {
193
+ unlinkSync(join(this.capturesDir, file));
194
+ deleted++;
195
+ }
196
+ catch {
197
+ }
198
+ }
199
+ return deleted;
200
+ }
201
+ }
202
+ let instance = null;
203
+ export function getReplayEngine(capturesDir) {
204
+ if (!instance) {
205
+ instance = new ReplayEngine(capturesDir);
206
+ }
207
+ return instance;
208
+ }
@@ -0,0 +1,24 @@
1
+ import type { WebhookProvider, GeneratedSignature, HeaderEntry } from "../types/index.js";
2
+ export declare function generateStripeSignature(payload: string, secret: string, timestamp?: number): GeneratedSignature;
3
+ export declare function generateGitHubSignature(payload: string, secret: string): GeneratedSignature;
4
+ export declare function generateShopifySignature(payload: string, secret: string): GeneratedSignature;
5
+ export declare function generateTwilioSignature(payload: string, secret: string, url: string): GeneratedSignature;
6
+ export declare function generateSlackSignature(payload: string, secret: string, timestamp?: number): GeneratedSignature;
7
+ export declare function generateLinearSignature(payload: string, secret: string): GeneratedSignature;
8
+ export declare function generateClerkSignature(payload: string, secret: string, timestamp?: number, webhookId?: string): GeneratedSignature;
9
+ export declare function generateSendGridSignature(payload: string, secret: string, timestamp?: number): GeneratedSignature;
10
+ export declare function generateRagieSignature(payload: string, secret: string): GeneratedSignature;
11
+ export declare function generateSignature(provider: WebhookProvider, payload: string, secret: string, options?: {
12
+ timestamp?: number;
13
+ url?: string;
14
+ webhookId?: string;
15
+ }): GeneratedSignature | null;
16
+ export declare function getProviderHeaders(provider: WebhookProvider, options?: {
17
+ timestamp?: number;
18
+ webhookId?: string;
19
+ event?: string;
20
+ }): HeaderEntry[];
21
+ export declare function verifySignature(provider: WebhookProvider, payload: string, signature: string, secret: string, options?: {
22
+ timestamp?: number;
23
+ url?: string;
24
+ }): boolean;