@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,292 @@
1
+ import { createServer, } from "http";
2
+ import { WebSocketServer } from "ws";
3
+ import { writeFileSync, mkdirSync, existsSync, readdirSync, readFileSync, unlinkSync, } from "fs";
4
+ import { join } from "path";
5
+ import { randomUUID } from "crypto";
6
+ import { homedir } from "os";
7
+ export class CaptureServer {
8
+ server = null;
9
+ wss = null;
10
+ capturesDir;
11
+ clients = new Set();
12
+ captureCount = 0;
13
+ enableWebSocket;
14
+ onCapture;
15
+ constructor(options) {
16
+ const capturesDir = typeof options === "string" ? options : options?.capturesDir;
17
+ this.capturesDir =
18
+ capturesDir || join(homedir(), ".better-webhook", "captures");
19
+ this.enableWebSocket =
20
+ typeof options === "object" ? options?.enableWebSocket !== false : true;
21
+ this.onCapture =
22
+ typeof options === "object" ? options?.onCapture : undefined;
23
+ if (!existsSync(this.capturesDir)) {
24
+ mkdirSync(this.capturesDir, { recursive: true });
25
+ }
26
+ }
27
+ getCapturesDir() {
28
+ return this.capturesDir;
29
+ }
30
+ async start(port = 3001, host = "0.0.0.0") {
31
+ return new Promise((resolve, reject) => {
32
+ this.server = createServer((req, res) => this.handleRequest(req, res));
33
+ if (this.enableWebSocket) {
34
+ this.wss = new WebSocketServer({ server: this.server });
35
+ this.wss.on("connection", (ws) => {
36
+ this.clients.add(ws);
37
+ console.log("šŸ“” Dashboard connected via WebSocket");
38
+ ws.on("close", () => {
39
+ this.clients.delete(ws);
40
+ console.log("šŸ“” Dashboard disconnected");
41
+ });
42
+ ws.on("error", (error) => {
43
+ console.error("WebSocket error:", error);
44
+ this.clients.delete(ws);
45
+ });
46
+ this.sendToClient(ws, {
47
+ type: "captures_updated",
48
+ payload: {
49
+ captures: this.listCaptures(),
50
+ count: this.captureCount,
51
+ },
52
+ });
53
+ });
54
+ }
55
+ this.server.on("error", (err) => {
56
+ if (err.code === "EADDRINUSE") {
57
+ reject(new Error(`Port ${port} is already in use`));
58
+ }
59
+ else {
60
+ reject(err);
61
+ }
62
+ });
63
+ this.server.listen(port, host, () => {
64
+ const address = this.server?.address();
65
+ const actualPort = typeof address === "object" ? address?.port || port : port;
66
+ console.log(`\nšŸŽ£ Webhook Capture Server`);
67
+ console.log(` Listening on http://${host === "0.0.0.0" ? "localhost" : host}:${actualPort}`);
68
+ console.log(` šŸ“ Captures saved to: ${this.capturesDir}`);
69
+ console.log(` šŸ’” Send webhooks to any path to capture them`);
70
+ if (this.enableWebSocket) {
71
+ console.log(` 🌐 WebSocket available for real-time updates`);
72
+ }
73
+ console.log(` ā¹ļø Press Ctrl+C to stop\n`);
74
+ resolve(actualPort);
75
+ });
76
+ });
77
+ }
78
+ async stop() {
79
+ return new Promise((resolve) => {
80
+ for (const client of this.clients) {
81
+ client.close();
82
+ }
83
+ this.clients.clear();
84
+ if (this.wss) {
85
+ this.wss.close();
86
+ this.wss = null;
87
+ }
88
+ if (this.server) {
89
+ this.server.close(() => {
90
+ console.log("\nšŸ›‘ Capture server stopped");
91
+ resolve();
92
+ });
93
+ }
94
+ else {
95
+ resolve();
96
+ }
97
+ });
98
+ }
99
+ async handleRequest(req, res) {
100
+ if (req.headers.upgrade?.toLowerCase() === "websocket") {
101
+ return;
102
+ }
103
+ const timestamp = new Date().toISOString();
104
+ const id = randomUUID();
105
+ const url = req.url || "/";
106
+ const urlParts = new URL(url, `http://${req.headers.host || "localhost"}`);
107
+ const query = {};
108
+ for (const [key, value] of urlParts.searchParams.entries()) {
109
+ if (query[key]) {
110
+ if (Array.isArray(query[key])) {
111
+ query[key].push(value);
112
+ }
113
+ else {
114
+ query[key] = [query[key], value];
115
+ }
116
+ }
117
+ else {
118
+ query[key] = value;
119
+ }
120
+ }
121
+ const chunks = [];
122
+ for await (const chunk of req) {
123
+ chunks.push(chunk);
124
+ }
125
+ const rawBody = Buffer.concat(chunks).toString("utf8");
126
+ let body = null;
127
+ const contentType = req.headers["content-type"] || "";
128
+ if (rawBody) {
129
+ if (contentType.includes("application/json")) {
130
+ try {
131
+ body = JSON.parse(rawBody);
132
+ }
133
+ catch {
134
+ body = rawBody;
135
+ }
136
+ }
137
+ else if (contentType.includes("application/x-www-form-urlencoded")) {
138
+ body = Object.fromEntries(new URLSearchParams(rawBody));
139
+ }
140
+ else {
141
+ body = rawBody;
142
+ }
143
+ }
144
+ const provider = this.detectProvider(req.headers);
145
+ const captured = {
146
+ id,
147
+ timestamp,
148
+ method: (req.method || "GET"),
149
+ url,
150
+ path: urlParts.pathname,
151
+ headers: req.headers,
152
+ body,
153
+ rawBody,
154
+ query,
155
+ provider,
156
+ contentType: contentType || undefined,
157
+ contentLength: rawBody.length,
158
+ };
159
+ const date = new Date(timestamp);
160
+ const dateStr = date.toISOString().split("T")[0];
161
+ const timeStr = date
162
+ .toISOString()
163
+ .split("T")[1]
164
+ ?.replace(/[:.]/g, "-")
165
+ .slice(0, 8);
166
+ const filename = `${dateStr}_${timeStr}_${id.slice(0, 8)}.json`;
167
+ const filepath = join(this.capturesDir, filename);
168
+ try {
169
+ writeFileSync(filepath, JSON.stringify(captured, null, 2));
170
+ this.captureCount++;
171
+ const providerStr = provider ? ` [${provider}]` : "";
172
+ console.log(`šŸ“¦ ${req.method} ${urlParts.pathname}${providerStr} -> ${filename}`);
173
+ this.onCapture?.({ file: filename, capture: captured });
174
+ if (this.enableWebSocket) {
175
+ this.broadcast({
176
+ type: "capture",
177
+ payload: {
178
+ file: filename,
179
+ capture: captured,
180
+ },
181
+ });
182
+ }
183
+ }
184
+ catch (error) {
185
+ console.error(`āŒ Failed to save capture:`, error);
186
+ }
187
+ res.statusCode = 200;
188
+ res.setHeader("Content-Type", "application/json");
189
+ res.setHeader("X-Capture-Id", id);
190
+ res.end(JSON.stringify({
191
+ success: true,
192
+ message: "Webhook captured successfully",
193
+ id,
194
+ timestamp,
195
+ file: filename,
196
+ }));
197
+ }
198
+ detectProvider(headers) {
199
+ if (headers["stripe-signature"]) {
200
+ return "stripe";
201
+ }
202
+ if (headers["x-github-event"] || headers["x-hub-signature-256"]) {
203
+ return "github";
204
+ }
205
+ if (headers["x-ragie-delivery"]) {
206
+ return "ragie";
207
+ }
208
+ if (headers["x-shopify-hmac-sha256"] || headers["x-shopify-topic"]) {
209
+ return "shopify";
210
+ }
211
+ if (headers["x-twilio-signature"]) {
212
+ return "twilio";
213
+ }
214
+ if (headers["x-twilio-email-event-webhook-signature"]) {
215
+ return "sendgrid";
216
+ }
217
+ if (headers["x-slack-signature"]) {
218
+ return "slack";
219
+ }
220
+ if (headers["x-signature-ed25519"]) {
221
+ return "discord";
222
+ }
223
+ if (headers["linear-signature"]) {
224
+ return "linear";
225
+ }
226
+ if (headers["svix-signature"]) {
227
+ return "clerk";
228
+ }
229
+ return undefined;
230
+ }
231
+ broadcast(message) {
232
+ const data = JSON.stringify(message);
233
+ for (const client of this.clients) {
234
+ if (client.readyState === 1) {
235
+ client.send(data);
236
+ }
237
+ }
238
+ }
239
+ sendToClient(client, message) {
240
+ if (client.readyState === 1) {
241
+ client.send(JSON.stringify(message));
242
+ }
243
+ }
244
+ listCaptures(limit = 100) {
245
+ if (!existsSync(this.capturesDir)) {
246
+ return [];
247
+ }
248
+ const files = readdirSync(this.capturesDir)
249
+ .filter((f) => f.endsWith(".json"))
250
+ .sort()
251
+ .reverse()
252
+ .slice(0, limit);
253
+ const captures = [];
254
+ for (const file of files) {
255
+ try {
256
+ const content = readFileSync(join(this.capturesDir, file), "utf-8");
257
+ const capture = JSON.parse(content);
258
+ captures.push({ file, capture });
259
+ }
260
+ catch {
261
+ }
262
+ }
263
+ return captures;
264
+ }
265
+ getCapture(captureId) {
266
+ const captures = this.listCaptures(1000);
267
+ return (captures.find((c) => c.capture.id === captureId || c.file.includes(captureId)) || null);
268
+ }
269
+ deleteCapture(captureId) {
270
+ const capture = this.getCapture(captureId);
271
+ if (!capture) {
272
+ return false;
273
+ }
274
+ try {
275
+ unlinkSync(join(this.capturesDir, capture.file));
276
+ return true;
277
+ }
278
+ catch {
279
+ return false;
280
+ }
281
+ }
282
+ getClientCount() {
283
+ return this.clients.size;
284
+ }
285
+ }
286
+ let instance = null;
287
+ export function getCaptureServer(capturesDir) {
288
+ if (!instance) {
289
+ instance = new CaptureServer(capturesDir);
290
+ }
291
+ return instance;
292
+ }
@@ -0,0 +1,8 @@
1
+ import express from "express";
2
+ import { type WebSocketMessage } from "../types/index.js";
3
+ export interface DashboardApiOptions {
4
+ capturesDir?: string;
5
+ templatesBaseDir?: string;
6
+ broadcast?: (message: WebSocketMessage) => void;
7
+ }
8
+ export declare function createDashboardApiRouter(options?: DashboardApiOptions): express.Router;
@@ -0,0 +1,271 @@
1
+ import express from "express";
2
+ import { z } from "zod";
3
+ import { TemplateManager } from "./template-manager.js";
4
+ import { ReplayEngine } from "./replay-engine.js";
5
+ import { executeTemplate } from "./executor.js";
6
+ import { HeaderEntrySchema, HttpMethodSchema, } from "../types/index.js";
7
+ function jsonError(res, status, message) {
8
+ return res.status(status).json({ error: message });
9
+ }
10
+ function getSecretEnvVarName(provider) {
11
+ const envVarMap = {
12
+ github: "GITHUB_WEBHOOK_SECRET",
13
+ stripe: "STRIPE_WEBHOOK_SECRET",
14
+ shopify: "SHOPIFY_WEBHOOK_SECRET",
15
+ twilio: "TWILIO_WEBHOOK_SECRET",
16
+ ragie: "RAGIE_WEBHOOK_SECRET",
17
+ slack: "SLACK_WEBHOOK_SECRET",
18
+ linear: "LINEAR_WEBHOOK_SECRET",
19
+ clerk: "CLERK_WEBHOOK_SECRET",
20
+ sendgrid: "SENDGRID_WEBHOOK_SECRET",
21
+ discord: "DISCORD_WEBHOOK_SECRET",
22
+ custom: "WEBHOOK_SECRET",
23
+ };
24
+ return envVarMap[provider] || "WEBHOOK_SECRET";
25
+ }
26
+ const ReplayBodySchema = z.object({
27
+ captureId: z.string().min(1),
28
+ targetUrl: z.string().min(1),
29
+ method: HttpMethodSchema.optional(),
30
+ headers: z.array(HeaderEntrySchema).optional(),
31
+ });
32
+ const TemplateDownloadBodySchema = z.object({
33
+ id: z.string().min(1),
34
+ });
35
+ const RunTemplateBodySchema = z.object({
36
+ templateId: z.string().min(1),
37
+ url: z.string().min(1),
38
+ secret: z.string().optional(),
39
+ headers: z.array(HeaderEntrySchema).optional(),
40
+ });
41
+ export function createDashboardApiRouter(options = {}) {
42
+ const router = express.Router();
43
+ const replayEngine = new ReplayEngine(options.capturesDir);
44
+ const templateManager = new TemplateManager(options.templatesBaseDir);
45
+ const broadcast = options.broadcast;
46
+ const broadcastCaptures = () => {
47
+ if (!broadcast)
48
+ return;
49
+ const captures = replayEngine.listCaptures(200);
50
+ broadcast({
51
+ type: "captures_updated",
52
+ payload: { captures, count: captures.length },
53
+ });
54
+ };
55
+ const broadcastTemplates = async () => {
56
+ if (!broadcast)
57
+ return;
58
+ const local = templateManager.listLocalTemplates();
59
+ let remote = [];
60
+ try {
61
+ const index = await templateManager.fetchRemoteIndex(false);
62
+ const localIds = new Set(local.map((t) => t.id));
63
+ remote = index.templates.map((metadata) => ({
64
+ metadata,
65
+ isDownloaded: localIds.has(metadata.id),
66
+ }));
67
+ }
68
+ catch {
69
+ remote = [];
70
+ }
71
+ broadcast({
72
+ type: "templates_updated",
73
+ payload: { local, remote },
74
+ });
75
+ };
76
+ router.get("/captures", (req, res) => {
77
+ const limitRaw = typeof req.query.limit === "string" ? req.query.limit : "";
78
+ const providerRaw = typeof req.query.provider === "string" ? req.query.provider : "";
79
+ const qRaw = typeof req.query.q === "string" ? req.query.q : "";
80
+ const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 50;
81
+ if (!Number.isFinite(limit) || limit <= 0 || limit > 5000) {
82
+ return jsonError(res, 400, "Invalid limit");
83
+ }
84
+ const q = qRaw.trim();
85
+ const provider = providerRaw.trim();
86
+ let captures = q
87
+ ? replayEngine.searchCaptures(q)
88
+ : replayEngine.listCaptures(Math.max(limit, 1000));
89
+ if (provider) {
90
+ captures = captures.filter((c) => (c.capture.provider || "").toLowerCase() === provider.toLowerCase());
91
+ }
92
+ captures = captures.slice(0, limit);
93
+ return res.json({ captures, count: captures.length });
94
+ });
95
+ router.get("/captures/:id", (req, res) => {
96
+ const id = req.params.id;
97
+ if (!id) {
98
+ return jsonError(res, 400, "Missing capture id");
99
+ }
100
+ const captureFile = replayEngine.getCapture(id);
101
+ if (!captureFile) {
102
+ return jsonError(res, 404, "Capture not found");
103
+ }
104
+ return res.json(captureFile);
105
+ });
106
+ router.delete("/captures/:id", (req, res) => {
107
+ const id = req.params.id;
108
+ if (!id) {
109
+ return jsonError(res, 400, "Missing capture id");
110
+ }
111
+ const deleted = replayEngine.deleteCapture(id);
112
+ if (!deleted) {
113
+ return jsonError(res, 404, "Capture not found");
114
+ }
115
+ broadcastCaptures();
116
+ return res.json({ success: true });
117
+ });
118
+ router.delete("/captures", (_req, res) => {
119
+ const deleted = replayEngine.deleteAllCaptures();
120
+ broadcastCaptures();
121
+ return res.json({ success: true, deleted });
122
+ });
123
+ router.post("/replay", express.json({ limit: "5mb" }), async (req, res) => {
124
+ const parsed = ReplayBodySchema.safeParse(req.body);
125
+ if (!parsed.success) {
126
+ return jsonError(res, 400, parsed.error.issues[0]?.message || "Invalid body");
127
+ }
128
+ const { captureId, targetUrl, method, headers } = parsed.data;
129
+ try {
130
+ new URL(targetUrl);
131
+ }
132
+ catch {
133
+ return jsonError(res, 400, "Invalid targetUrl");
134
+ }
135
+ try {
136
+ const result = await replayEngine.replay(captureId, {
137
+ targetUrl,
138
+ method,
139
+ headers,
140
+ });
141
+ broadcast?.({
142
+ type: "replay_result",
143
+ payload: { captureId, targetUrl, result },
144
+ });
145
+ return res.json(result);
146
+ }
147
+ catch (error) {
148
+ return jsonError(res, 400, error?.message || "Replay failed");
149
+ }
150
+ });
151
+ router.get("/templates/local", (_req, res) => {
152
+ const local = templateManager.listLocalTemplates();
153
+ return res.json({ templates: local, count: local.length });
154
+ });
155
+ router.get("/templates/remote", async (req, res) => {
156
+ const refresh = typeof req.query.refresh === "string"
157
+ ? req.query.refresh === "1" ||
158
+ req.query.refresh.toLowerCase() === "true"
159
+ : false;
160
+ try {
161
+ const index = await templateManager.fetchRemoteIndex(refresh);
162
+ const localIds = new Set(templateManager.listLocalTemplates().map((t) => t.id));
163
+ const remote = index.templates.map((metadata) => ({
164
+ metadata,
165
+ isDownloaded: localIds.has(metadata.id),
166
+ }));
167
+ return res.json({ templates: remote, count: remote.length });
168
+ }
169
+ catch (error) {
170
+ return jsonError(res, 500, error?.message || "Failed to fetch remote templates");
171
+ }
172
+ });
173
+ router.post("/templates/download", express.json({ limit: "2mb" }), async (req, res) => {
174
+ const parsed = TemplateDownloadBodySchema.safeParse(req.body);
175
+ if (!parsed.success) {
176
+ return jsonError(res, 400, parsed.error.issues[0]?.message || "Invalid body");
177
+ }
178
+ try {
179
+ const template = await templateManager.downloadTemplate(parsed.data.id);
180
+ void broadcastTemplates();
181
+ return res.json({ success: true, template });
182
+ }
183
+ catch (error) {
184
+ return jsonError(res, 400, error?.message || "Download failed");
185
+ }
186
+ });
187
+ router.post("/templates/download-all", express.json({ limit: "1mb" }), async (_req, res) => {
188
+ try {
189
+ const index = await templateManager.fetchRemoteIndex(true);
190
+ const localIds = new Set(templateManager.listLocalTemplates().map((t) => t.id));
191
+ const toDownload = index.templates.filter((t) => !localIds.has(t.id));
192
+ const downloaded = [];
193
+ const failed = [];
194
+ for (const t of toDownload) {
195
+ try {
196
+ await templateManager.downloadTemplate(t.id);
197
+ downloaded.push(t.id);
198
+ }
199
+ catch (e) {
200
+ failed.push({ id: t.id, error: e?.message || "Failed" });
201
+ }
202
+ }
203
+ return res.json({
204
+ success: true,
205
+ total: index.templates.length,
206
+ downloaded,
207
+ failed,
208
+ });
209
+ }
210
+ catch (error) {
211
+ return jsonError(res, 500, error?.message || "Download-all failed");
212
+ }
213
+ });
214
+ router.post("/run", express.json({ limit: "10mb" }), async (req, res) => {
215
+ const parsed = RunTemplateBodySchema.safeParse(req.body);
216
+ if (!parsed.success) {
217
+ return jsonError(res, 400, parsed.error.issues[0]?.message || "Invalid body");
218
+ }
219
+ let { templateId, url, secret, headers } = parsed.data;
220
+ try {
221
+ new URL(url);
222
+ }
223
+ catch {
224
+ return jsonError(res, 400, "Invalid url");
225
+ }
226
+ if (templateId.startsWith("remote:")) {
227
+ templateId = templateId.slice("remote:".length);
228
+ try {
229
+ await templateManager.downloadTemplate(templateId);
230
+ }
231
+ catch (error) {
232
+ return jsonError(res, 400, error?.message || "Failed to download template");
233
+ }
234
+ }
235
+ let localTemplate = templateManager.getLocalTemplate(templateId);
236
+ if (!localTemplate) {
237
+ try {
238
+ await templateManager.downloadTemplate(templateId);
239
+ localTemplate = templateManager.getLocalTemplate(templateId);
240
+ }
241
+ catch {
242
+ }
243
+ }
244
+ if (!localTemplate) {
245
+ return jsonError(res, 404, "Template not found");
246
+ }
247
+ if (!secret && localTemplate.metadata.provider) {
248
+ const envVarName = getSecretEnvVarName(localTemplate.metadata.provider);
249
+ secret = process.env[envVarName];
250
+ }
251
+ const safeHeaders = headers?.length
252
+ ? headers
253
+ : undefined;
254
+ try {
255
+ const result = await executeTemplate(localTemplate.template, {
256
+ url,
257
+ secret,
258
+ headers: safeHeaders,
259
+ });
260
+ broadcast?.({
261
+ type: "replay_result",
262
+ payload: { templateId, url, result },
263
+ });
264
+ return res.json(result);
265
+ }
266
+ catch (error) {
267
+ return jsonError(res, 400, error?.message || "Run failed");
268
+ }
269
+ });
270
+ return router;
271
+ }
@@ -0,0 +1,20 @@
1
+ import express from "express";
2
+ import { type Server } from "http";
3
+ import { type DashboardApiOptions } from "./dashboard-api.js";
4
+ import { CaptureServer } from "./capture-server.js";
5
+ export interface DashboardServerOptions extends DashboardApiOptions {
6
+ host?: string;
7
+ port?: number;
8
+ captureHost?: string;
9
+ capturePort?: number;
10
+ startCapture?: boolean;
11
+ }
12
+ export declare function startDashboardServer(options?: DashboardServerOptions): Promise<{
13
+ app: express.Express;
14
+ server: Server;
15
+ url: string;
16
+ capture?: {
17
+ server: CaptureServer;
18
+ url: string;
19
+ };
20
+ }>;