@better-webhook/cli 3.9.0 → 3.10.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/_binary_entry.js +29 -0
- package/dist/commands/capture.d.ts +2 -0
- package/dist/commands/capture.js +33 -0
- package/dist/commands/captures.d.ts +2 -0
- package/dist/commands/captures.js +316 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +70 -0
- package/dist/commands/index.d.ts +6 -0
- package/dist/commands/index.js +6 -0
- package/dist/commands/replay.d.ts +2 -0
- package/dist/commands/replay.js +140 -0
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +182 -0
- package/dist/commands/templates.d.ts +2 -0
- package/dist/commands/templates.js +285 -0
- package/dist/core/capture-server.d.ts +37 -0
- package/dist/core/capture-server.js +400 -0
- package/dist/core/capture-server.test.d.ts +1 -0
- package/dist/core/capture-server.test.js +86 -0
- package/dist/core/cli-version.d.ts +1 -0
- package/dist/core/cli-version.js +30 -0
- package/dist/core/cli-version.test.d.ts +1 -0
- package/dist/core/cli-version.test.js +42 -0
- package/dist/core/dashboard-api.d.ts +8 -0
- package/dist/core/dashboard-api.js +333 -0
- package/dist/core/dashboard-server.d.ts +24 -0
- package/dist/core/dashboard-server.js +224 -0
- package/dist/core/debug-output.d.ts +3 -0
- package/dist/core/debug-output.js +69 -0
- package/dist/core/debug-verify.d.ts +25 -0
- package/dist/core/debug-verify.js +253 -0
- package/dist/core/executor.d.ts +11 -0
- package/dist/core/executor.js +152 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +5 -0
- package/dist/core/replay-engine.d.ts +20 -0
- package/dist/core/replay-engine.js +293 -0
- package/dist/core/replay-engine.test.d.ts +1 -0
- package/dist/core/replay-engine.test.js +482 -0
- package/dist/core/runtime-paths.d.ts +2 -0
- package/dist/core/runtime-paths.js +65 -0
- package/dist/core/runtime-paths.test.d.ts +1 -0
- package/dist/core/runtime-paths.test.js +50 -0
- package/dist/core/signature.d.ts +25 -0
- package/dist/core/signature.js +224 -0
- package/dist/core/signature.test.d.ts +1 -0
- package/dist/core/signature.test.js +38 -0
- package/dist/core/template-manager.d.ts +33 -0
- package/dist/core/template-manager.js +313 -0
- package/dist/core/template-manager.test.d.ts +1 -0
- package/dist/core/template-manager.test.js +236 -0
- package/dist/index.cjs +135 -20
- package/dist/index.js +123 -8
- package/dist/types/index.d.ts +312 -0
- package/dist/types/index.js +87 -0
- package/package.json +1 -1
|
@@ -0,0 +1,333 @@
|
|
|
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
|
+
recall: "RECALL_WEBHOOK_SECRET",
|
|
18
|
+
slack: "SLACK_WEBHOOK_SECRET",
|
|
19
|
+
linear: "LINEAR_WEBHOOK_SECRET",
|
|
20
|
+
clerk: "CLERK_WEBHOOK_SECRET",
|
|
21
|
+
sendgrid: "SENDGRID_WEBHOOK_SECRET",
|
|
22
|
+
discord: "DISCORD_WEBHOOK_SECRET",
|
|
23
|
+
custom: "WEBHOOK_SECRET",
|
|
24
|
+
};
|
|
25
|
+
return envVarMap[provider] || "WEBHOOK_SECRET";
|
|
26
|
+
}
|
|
27
|
+
const ReplayBodySchema = z.object({
|
|
28
|
+
captureId: z.string().min(1),
|
|
29
|
+
targetUrl: z.string().min(1),
|
|
30
|
+
method: HttpMethodSchema.optional(),
|
|
31
|
+
headers: z.array(HeaderEntrySchema).optional(),
|
|
32
|
+
});
|
|
33
|
+
const TemplateDownloadBodySchema = z.object({
|
|
34
|
+
id: z.string().min(1),
|
|
35
|
+
});
|
|
36
|
+
const TemplateIdSchema = z
|
|
37
|
+
.string()
|
|
38
|
+
.regex(/^[a-z0-9][a-z0-9._-]*$/i, "ID must start with alphanumeric and contain only letters, numbers, dots, underscores, and hyphens")
|
|
39
|
+
.max(128, "ID must be 128 characters or less")
|
|
40
|
+
.refine((val) => !val.includes("/") && !val.includes("\\") && !val.includes(".."), "ID cannot contain path separators or parent directory references");
|
|
41
|
+
const RunTemplateBodySchema = z.object({
|
|
42
|
+
templateId: z.string().min(1),
|
|
43
|
+
url: z.string().min(1),
|
|
44
|
+
secret: z.string().optional(),
|
|
45
|
+
headers: z.array(HeaderEntrySchema).optional(),
|
|
46
|
+
});
|
|
47
|
+
const SaveAsTemplateBodySchema = z.object({
|
|
48
|
+
captureId: z.string().min(1),
|
|
49
|
+
id: TemplateIdSchema.optional(),
|
|
50
|
+
name: z.string().optional(),
|
|
51
|
+
event: z.string().optional(),
|
|
52
|
+
description: z.string().optional(),
|
|
53
|
+
url: z.string().optional(),
|
|
54
|
+
overwrite: z.boolean().optional(),
|
|
55
|
+
});
|
|
56
|
+
export function createDashboardApiRouter(options = {}) {
|
|
57
|
+
const router = express.Router();
|
|
58
|
+
const replayEngine = new ReplayEngine(options.capturesDir);
|
|
59
|
+
const templateManager = new TemplateManager(options.templatesBaseDir);
|
|
60
|
+
const broadcast = options.broadcast;
|
|
61
|
+
const broadcastCaptures = () => {
|
|
62
|
+
if (!broadcast)
|
|
63
|
+
return;
|
|
64
|
+
const captures = replayEngine.listCaptures(200);
|
|
65
|
+
broadcast({
|
|
66
|
+
type: "captures_updated",
|
|
67
|
+
payload: { captures, count: captures.length },
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
const broadcastTemplates = async () => {
|
|
71
|
+
if (!broadcast)
|
|
72
|
+
return;
|
|
73
|
+
try {
|
|
74
|
+
const local = templateManager.listLocalTemplates();
|
|
75
|
+
let remote = [];
|
|
76
|
+
try {
|
|
77
|
+
const index = await templateManager.fetchRemoteIndex(false);
|
|
78
|
+
const localIds = new Set(local.map((t) => t.id));
|
|
79
|
+
remote = index.templates.map((metadata) => ({
|
|
80
|
+
metadata,
|
|
81
|
+
isDownloaded: localIds.has(metadata.id),
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
remote = [];
|
|
86
|
+
}
|
|
87
|
+
broadcast({
|
|
88
|
+
type: "templates_updated",
|
|
89
|
+
payload: { local, remote },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
console.error("[dashboard-api] Failed to broadcast templates:", error);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
router.get("/captures", (req, res) => {
|
|
97
|
+
const limitRaw = typeof req.query.limit === "string" ? req.query.limit : "";
|
|
98
|
+
const providerRaw = typeof req.query.provider === "string" ? req.query.provider : "";
|
|
99
|
+
const qRaw = typeof req.query.q === "string" ? req.query.q : "";
|
|
100
|
+
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 50;
|
|
101
|
+
if (!Number.isFinite(limit) || limit <= 0 || limit > 5000) {
|
|
102
|
+
return jsonError(res, 400, "Invalid limit");
|
|
103
|
+
}
|
|
104
|
+
const q = qRaw.trim();
|
|
105
|
+
const provider = providerRaw.trim();
|
|
106
|
+
let captures = q
|
|
107
|
+
? replayEngine.searchCaptures(q)
|
|
108
|
+
: replayEngine.listCaptures(Math.max(limit, 1000));
|
|
109
|
+
if (provider) {
|
|
110
|
+
captures = captures.filter((c) => (c.capture.provider || "").toLowerCase() === provider.toLowerCase());
|
|
111
|
+
}
|
|
112
|
+
captures = captures.slice(0, limit);
|
|
113
|
+
return res.json({ captures, count: captures.length });
|
|
114
|
+
});
|
|
115
|
+
router.get("/captures/:id", (req, res) => {
|
|
116
|
+
const id = req.params.id;
|
|
117
|
+
if (!id) {
|
|
118
|
+
return jsonError(res, 400, "Missing capture id");
|
|
119
|
+
}
|
|
120
|
+
const captureFile = replayEngine.getCapture(id);
|
|
121
|
+
if (!captureFile) {
|
|
122
|
+
return jsonError(res, 404, "Capture not found");
|
|
123
|
+
}
|
|
124
|
+
return res.json(captureFile);
|
|
125
|
+
});
|
|
126
|
+
router.delete("/captures/:id", (req, res) => {
|
|
127
|
+
const id = req.params.id;
|
|
128
|
+
if (!id) {
|
|
129
|
+
return jsonError(res, 400, "Missing capture id");
|
|
130
|
+
}
|
|
131
|
+
const deleted = replayEngine.deleteCapture(id);
|
|
132
|
+
if (!deleted) {
|
|
133
|
+
return jsonError(res, 404, "Capture not found");
|
|
134
|
+
}
|
|
135
|
+
broadcastCaptures();
|
|
136
|
+
return res.json({ success: true });
|
|
137
|
+
});
|
|
138
|
+
router.delete("/captures", (_req, res) => {
|
|
139
|
+
const deleted = replayEngine.deleteAllCaptures();
|
|
140
|
+
broadcastCaptures();
|
|
141
|
+
return res.json({ success: true, deleted });
|
|
142
|
+
});
|
|
143
|
+
router.post("/replay", express.json({ limit: "5mb" }), async (req, res) => {
|
|
144
|
+
const parsed = ReplayBodySchema.safeParse(req.body);
|
|
145
|
+
if (!parsed.success) {
|
|
146
|
+
return jsonError(res, 400, parsed.error.issues[0]?.message || "Invalid body");
|
|
147
|
+
}
|
|
148
|
+
const { captureId, targetUrl, method, headers } = parsed.data;
|
|
149
|
+
try {
|
|
150
|
+
new URL(targetUrl);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return jsonError(res, 400, "Invalid targetUrl");
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const result = await replayEngine.replay(captureId, {
|
|
157
|
+
targetUrl,
|
|
158
|
+
method,
|
|
159
|
+
headers,
|
|
160
|
+
});
|
|
161
|
+
broadcast?.({
|
|
162
|
+
type: "replay_result",
|
|
163
|
+
payload: { captureId, targetUrl, result },
|
|
164
|
+
});
|
|
165
|
+
return res.json(result);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return jsonError(res, 400, error?.message || "Replay failed");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
router.get("/templates/local", (_req, res) => {
|
|
172
|
+
const local = templateManager.listLocalTemplates();
|
|
173
|
+
return res.json({ templates: local, count: local.length });
|
|
174
|
+
});
|
|
175
|
+
router.get("/templates/remote", async (req, res) => {
|
|
176
|
+
const refresh = typeof req.query.refresh === "string"
|
|
177
|
+
? req.query.refresh === "1" ||
|
|
178
|
+
req.query.refresh.toLowerCase() === "true"
|
|
179
|
+
: false;
|
|
180
|
+
try {
|
|
181
|
+
const index = await templateManager.fetchRemoteIndex(refresh);
|
|
182
|
+
const localIds = new Set(templateManager.listLocalTemplates().map((t) => t.id));
|
|
183
|
+
const remote = index.templates.map((metadata) => ({
|
|
184
|
+
metadata,
|
|
185
|
+
isDownloaded: localIds.has(metadata.id),
|
|
186
|
+
}));
|
|
187
|
+
return res.json({ templates: remote, count: remote.length });
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
return jsonError(res, 500, error?.message || "Failed to fetch remote templates");
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
router.post("/templates/download", express.json({ limit: "2mb" }), async (req, res) => {
|
|
194
|
+
const parsed = TemplateDownloadBodySchema.safeParse(req.body);
|
|
195
|
+
if (!parsed.success) {
|
|
196
|
+
return jsonError(res, 400, parsed.error.issues[0]?.message || "Invalid body");
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const template = await templateManager.downloadTemplate(parsed.data.id);
|
|
200
|
+
void broadcastTemplates();
|
|
201
|
+
return res.json({ success: true, template });
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
return jsonError(res, 400, error?.message || "Download failed");
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
router.post("/templates/download-all", express.json({ limit: "1mb" }), async (_req, res) => {
|
|
208
|
+
try {
|
|
209
|
+
const index = await templateManager.fetchRemoteIndex(true);
|
|
210
|
+
const localIds = new Set(templateManager.listLocalTemplates().map((t) => t.id));
|
|
211
|
+
const toDownload = index.templates.filter((t) => !localIds.has(t.id));
|
|
212
|
+
const downloaded = [];
|
|
213
|
+
const failed = [];
|
|
214
|
+
for (const t of toDownload) {
|
|
215
|
+
try {
|
|
216
|
+
await templateManager.downloadTemplate(t.id);
|
|
217
|
+
downloaded.push(t.id);
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
failed.push({ id: t.id, error: e?.message || "Failed" });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return res.json({
|
|
224
|
+
success: true,
|
|
225
|
+
total: index.templates.length,
|
|
226
|
+
downloaded,
|
|
227
|
+
failed,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
return jsonError(res, 500, error?.message || "Download-all failed");
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
router.post("/run", express.json({ limit: "10mb" }), async (req, res) => {
|
|
235
|
+
const parsed = RunTemplateBodySchema.safeParse(req.body);
|
|
236
|
+
if (!parsed.success) {
|
|
237
|
+
return jsonError(res, 400, parsed.error.issues[0]?.message || "Invalid body");
|
|
238
|
+
}
|
|
239
|
+
let { templateId, url, secret, headers } = parsed.data;
|
|
240
|
+
try {
|
|
241
|
+
new URL(url);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return jsonError(res, 400, "Invalid url");
|
|
245
|
+
}
|
|
246
|
+
if (templateId.startsWith("remote:")) {
|
|
247
|
+
templateId = templateId.slice("remote:".length);
|
|
248
|
+
try {
|
|
249
|
+
await templateManager.downloadTemplate(templateId);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
return jsonError(res, 400, error?.message || "Failed to download template");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
let localTemplate = templateManager.getLocalTemplate(templateId);
|
|
256
|
+
if (!localTemplate) {
|
|
257
|
+
try {
|
|
258
|
+
await templateManager.downloadTemplate(templateId);
|
|
259
|
+
localTemplate = templateManager.getLocalTemplate(templateId);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!localTemplate) {
|
|
265
|
+
return jsonError(res, 404, "Template not found");
|
|
266
|
+
}
|
|
267
|
+
if (!secret && localTemplate.metadata.provider) {
|
|
268
|
+
const envVarName = getSecretEnvVarName(localTemplate.metadata.provider);
|
|
269
|
+
secret = process.env[envVarName];
|
|
270
|
+
}
|
|
271
|
+
const safeHeaders = headers?.length
|
|
272
|
+
? headers
|
|
273
|
+
: undefined;
|
|
274
|
+
try {
|
|
275
|
+
const result = await executeTemplate(localTemplate.template, {
|
|
276
|
+
url,
|
|
277
|
+
secret,
|
|
278
|
+
headers: safeHeaders,
|
|
279
|
+
});
|
|
280
|
+
broadcast?.({
|
|
281
|
+
type: "replay_result",
|
|
282
|
+
payload: { templateId, url, result },
|
|
283
|
+
});
|
|
284
|
+
return res.json(result);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
return jsonError(res, 400, error?.message || "Run failed");
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
router.post("/templates/from-capture", express.json({ limit: "5mb" }), async (req, res) => {
|
|
291
|
+
const parsed = SaveAsTemplateBodySchema.safeParse(req.body);
|
|
292
|
+
if (!parsed.success) {
|
|
293
|
+
return jsonError(res, 400, parsed.error.issues[0]?.message || "Invalid body");
|
|
294
|
+
}
|
|
295
|
+
const { captureId, id, name, event, description, url, overwrite } = parsed.data;
|
|
296
|
+
if (url !== undefined) {
|
|
297
|
+
try {
|
|
298
|
+
new URL(url);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return jsonError(res, 400, "Invalid url");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const captureFile = replayEngine.getCapture(captureId);
|
|
305
|
+
if (!captureFile) {
|
|
306
|
+
return jsonError(res, 404, "Capture not found");
|
|
307
|
+
}
|
|
308
|
+
const template = replayEngine.captureToTemplate(captureId, {
|
|
309
|
+
url,
|
|
310
|
+
event,
|
|
311
|
+
});
|
|
312
|
+
try {
|
|
313
|
+
const result = templateManager.saveUserTemplate(template, {
|
|
314
|
+
id,
|
|
315
|
+
name,
|
|
316
|
+
event: event || template.event,
|
|
317
|
+
description,
|
|
318
|
+
overwrite,
|
|
319
|
+
});
|
|
320
|
+
void broadcastTemplates();
|
|
321
|
+
return res.json({
|
|
322
|
+
success: true,
|
|
323
|
+
id: result.id,
|
|
324
|
+
filePath: result.filePath,
|
|
325
|
+
template: result.template,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
return jsonError(res, 400, error?.message || "Failed to save template");
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
return router;
|
|
333
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
declare global {
|
|
6
|
+
var embeddedDashboardFiles: Record<string, string> | undefined;
|
|
7
|
+
}
|
|
8
|
+
export interface DashboardServerOptions extends DashboardApiOptions {
|
|
9
|
+
host?: string;
|
|
10
|
+
port?: number;
|
|
11
|
+
captureHost?: string;
|
|
12
|
+
capturePort?: number;
|
|
13
|
+
verbose?: boolean;
|
|
14
|
+
startCapture?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function startDashboardServer(options?: DashboardServerOptions): Promise<{
|
|
17
|
+
app: express.Express;
|
|
18
|
+
server: Server;
|
|
19
|
+
url: string;
|
|
20
|
+
capture?: {
|
|
21
|
+
server: CaptureServer;
|
|
22
|
+
url: string;
|
|
23
|
+
};
|
|
24
|
+
}>;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { createServer } from "http";
|
|
3
|
+
import { WebSocketServer } from "ws";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { existsSync, readFileSync } from "fs";
|
|
6
|
+
import { createDashboardApiRouter, } from "./dashboard-api.js";
|
|
7
|
+
import { CaptureServer } from "./capture-server.js";
|
|
8
|
+
import { ReplayEngine } from "./replay-engine.js";
|
|
9
|
+
import { TemplateManager } from "./template-manager.js";
|
|
10
|
+
import { findCliPackageRoot, resolveRuntimeDir } from "./runtime-paths.js";
|
|
11
|
+
function isStandaloneBinary() {
|
|
12
|
+
if (typeof STANDALONE_BINARY !== "undefined" && STANDALONE_BINARY) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
if (typeof globalThis.embeddedDashboardFiles !== "undefined" &&
|
|
16
|
+
globalThis.embeddedDashboardFiles) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
function getMimeType(filePath) {
|
|
22
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
23
|
+
const mimeTypes = {
|
|
24
|
+
".html": "text/html; charset=utf-8",
|
|
25
|
+
".js": "application/javascript; charset=utf-8",
|
|
26
|
+
".css": "text/css; charset=utf-8",
|
|
27
|
+
".json": "application/json; charset=utf-8",
|
|
28
|
+
".png": "image/png",
|
|
29
|
+
".jpg": "image/jpeg",
|
|
30
|
+
".jpeg": "image/jpeg",
|
|
31
|
+
".gif": "image/gif",
|
|
32
|
+
".svg": "image/svg+xml",
|
|
33
|
+
".ico": "image/x-icon",
|
|
34
|
+
".woff": "font/woff",
|
|
35
|
+
".woff2": "font/woff2",
|
|
36
|
+
".ttf": "font/ttf",
|
|
37
|
+
".eot": "application/vnd.ms-fontobject",
|
|
38
|
+
};
|
|
39
|
+
return mimeTypes[ext] || "application/octet-stream";
|
|
40
|
+
}
|
|
41
|
+
function createEmbeddedDashboardMiddleware() {
|
|
42
|
+
const filePathMap = new Map();
|
|
43
|
+
let indexHtmlPath = null;
|
|
44
|
+
if (typeof globalThis.embeddedDashboardFiles !== "undefined") {
|
|
45
|
+
for (const [key, filePath] of Object.entries(globalThis.embeddedDashboardFiles)) {
|
|
46
|
+
const servePath = "/" + key.replace(/^dashboard\//, "");
|
|
47
|
+
filePathMap.set(servePath, filePath);
|
|
48
|
+
if (servePath === "/index.html") {
|
|
49
|
+
indexHtmlPath = filePath;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const staticMiddleware = async (req, res, next) => {
|
|
54
|
+
if (!Bun) {
|
|
55
|
+
return next();
|
|
56
|
+
}
|
|
57
|
+
const requestPath = req.path === "/" ? "/index.html" : req.path;
|
|
58
|
+
const filePath = filePathMap.get(requestPath);
|
|
59
|
+
if (filePath) {
|
|
60
|
+
try {
|
|
61
|
+
const file = Bun.file(filePath);
|
|
62
|
+
const content = await file.arrayBuffer();
|
|
63
|
+
res.setHeader("Content-Type", getMimeType(requestPath));
|
|
64
|
+
res.setHeader("Content-Length", content.byteLength);
|
|
65
|
+
res.send(Buffer.from(content));
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
console.error(`Failed to serve embedded file ${requestPath}:`, err);
|
|
69
|
+
next();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
next();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const spaFallback = async (req, res, next) => {
|
|
77
|
+
if (req.path.startsWith("/api") || req.path === "/health") {
|
|
78
|
+
return next();
|
|
79
|
+
}
|
|
80
|
+
if (!Bun || !indexHtmlPath) {
|
|
81
|
+
return next();
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const file = Bun.file(indexHtmlPath);
|
|
85
|
+
const content = await file.arrayBuffer();
|
|
86
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
87
|
+
res.setHeader("Content-Length", content.byteLength);
|
|
88
|
+
res.send(Buffer.from(content));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
next();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
return { staticMiddleware, spaFallback };
|
|
95
|
+
}
|
|
96
|
+
function resolveDashboardDistDir(runtimeDir, options = {}) {
|
|
97
|
+
const runtimePackageRoot = findCliPackageRoot(runtimeDir);
|
|
98
|
+
const includePackageRootDistCandidate = runtimePackageRoot !== undefined && runtimePackageRoot === runtimeDir;
|
|
99
|
+
const candidates = [
|
|
100
|
+
...(includePackageRootDistCandidate
|
|
101
|
+
? [path.resolve(runtimeDir, "dist", "dashboard")]
|
|
102
|
+
: []),
|
|
103
|
+
path.resolve(runtimeDir, "dashboard"),
|
|
104
|
+
path.resolve(runtimeDir, "..", "dashboard"),
|
|
105
|
+
path.resolve(runtimeDir, "..", "dist", "dashboard"),
|
|
106
|
+
path.resolve(runtimeDir, "..", "..", "dist", "dashboard"),
|
|
107
|
+
path.resolve(runtimeDir, "..", "dashboard", "dist"),
|
|
108
|
+
path.resolve(runtimeDir, "..", "..", "dashboard", "dist"),
|
|
109
|
+
path.resolve(runtimeDir, "..", "..", "..", "dashboard", "dist"),
|
|
110
|
+
];
|
|
111
|
+
if (options.verbose) {
|
|
112
|
+
console.debug(`[dashboard] dist resolution candidates: ${candidates.join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
for (const distDir of candidates) {
|
|
115
|
+
const indexHtml = path.join(distDir, "index.html");
|
|
116
|
+
if (existsSync(indexHtml)) {
|
|
117
|
+
return { distDir, indexHtml };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const details = candidates.map((p) => `- ${p}`).join("\n");
|
|
121
|
+
throw new Error(`Dashboard UI build output not found.\n` +
|
|
122
|
+
`Looked in:\n${details}\n\n` +
|
|
123
|
+
`Build it with:\n` +
|
|
124
|
+
`- pnpm --filter @better-webhook/dashboard build\n` +
|
|
125
|
+
`- pnpm --filter @better-webhook/cli build\n`);
|
|
126
|
+
}
|
|
127
|
+
export async function startDashboardServer(options = {}) {
|
|
128
|
+
const app = express();
|
|
129
|
+
app.get("/health", (_req, res) => {
|
|
130
|
+
res.json({ ok: true });
|
|
131
|
+
});
|
|
132
|
+
const clients = new Set();
|
|
133
|
+
const broadcast = (message) => {
|
|
134
|
+
const data = JSON.stringify(message);
|
|
135
|
+
for (const client of clients) {
|
|
136
|
+
if (client.readyState === 1) {
|
|
137
|
+
client.send(data);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
app.use("/api", createDashboardApiRouter({
|
|
142
|
+
capturesDir: options.capturesDir,
|
|
143
|
+
templatesBaseDir: options.templatesBaseDir,
|
|
144
|
+
broadcast,
|
|
145
|
+
}));
|
|
146
|
+
const host = options.host || "localhost";
|
|
147
|
+
const port = options.port ?? 4000;
|
|
148
|
+
if (isStandaloneBinary()) {
|
|
149
|
+
const { staticMiddleware, spaFallback } = createEmbeddedDashboardMiddleware();
|
|
150
|
+
app.use(staticMiddleware);
|
|
151
|
+
app.get("*", spaFallback);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const runtimeDir = resolveRuntimeDir();
|
|
155
|
+
const { distDir: dashboardDistDir, indexHtml: dashboardIndexHtml } = resolveDashboardDistDir(runtimeDir, { verbose: options.verbose });
|
|
156
|
+
app.use(express.static(dashboardDistDir));
|
|
157
|
+
app.get("*", (req, res, next) => {
|
|
158
|
+
if (req.path.startsWith("/api") || req.path === "/health")
|
|
159
|
+
return next();
|
|
160
|
+
res.sendFile(dashboardIndexHtml, (err) => {
|
|
161
|
+
if (err)
|
|
162
|
+
next();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const server = createServer(app);
|
|
167
|
+
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
168
|
+
wss.on("connection", async (ws) => {
|
|
169
|
+
clients.add(ws);
|
|
170
|
+
ws.on("close", () => clients.delete(ws));
|
|
171
|
+
ws.on("error", () => clients.delete(ws));
|
|
172
|
+
const replayEngine = new ReplayEngine(options.capturesDir);
|
|
173
|
+
const templateManager = new TemplateManager(options.templatesBaseDir);
|
|
174
|
+
const captures = replayEngine.listCaptures(200);
|
|
175
|
+
ws.send(JSON.stringify({
|
|
176
|
+
type: "captures_updated",
|
|
177
|
+
payload: { captures, count: captures.length },
|
|
178
|
+
}));
|
|
179
|
+
const local = templateManager.listLocalTemplates();
|
|
180
|
+
let remote = [];
|
|
181
|
+
try {
|
|
182
|
+
const index = await templateManager.fetchRemoteIndex(true);
|
|
183
|
+
const localIds = new Set(local.map((t) => t.id));
|
|
184
|
+
remote = index.templates.map((metadata) => ({
|
|
185
|
+
metadata,
|
|
186
|
+
isDownloaded: localIds.has(metadata.id),
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
remote = [];
|
|
191
|
+
}
|
|
192
|
+
ws.send(JSON.stringify({
|
|
193
|
+
type: "templates_updated",
|
|
194
|
+
payload: { local, remote },
|
|
195
|
+
}));
|
|
196
|
+
});
|
|
197
|
+
await new Promise((resolve, reject) => {
|
|
198
|
+
server.listen(port, host, () => resolve());
|
|
199
|
+
server.on("error", reject);
|
|
200
|
+
});
|
|
201
|
+
const url = `http://${host}:${port}`;
|
|
202
|
+
let capture;
|
|
203
|
+
const shouldStartCapture = options.startCapture !== false;
|
|
204
|
+
if (shouldStartCapture) {
|
|
205
|
+
const captureHost = options.captureHost || "0.0.0.0";
|
|
206
|
+
const capturePort = options.capturePort ?? 3001;
|
|
207
|
+
const captureServer = new CaptureServer({
|
|
208
|
+
capturesDir: options.capturesDir,
|
|
209
|
+
enableWebSocket: false,
|
|
210
|
+
onCapture: ({ file, capture }) => {
|
|
211
|
+
broadcast({
|
|
212
|
+
type: "capture",
|
|
213
|
+
payload: { file, capture },
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
const actualPort = await captureServer.start(capturePort, captureHost);
|
|
218
|
+
capture = {
|
|
219
|
+
server: captureServer,
|
|
220
|
+
url: `http://${captureHost === "0.0.0.0" ? "localhost" : captureHost}:${actualPort}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return { app, server, url, capture };
|
|
224
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
function truncate(str, maxLength) {
|
|
3
|
+
if (str.length <= maxLength)
|
|
4
|
+
return str;
|
|
5
|
+
return str.slice(0, maxLength - 3) + "...";
|
|
6
|
+
}
|
|
7
|
+
export function formatDebugOutput(result) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
const divider = chalk.gray("─".repeat(60));
|
|
10
|
+
const header = chalk.bold.cyan("Webhook Verification Debug");
|
|
11
|
+
lines.push("");
|
|
12
|
+
lines.push(divider);
|
|
13
|
+
lines.push(header);
|
|
14
|
+
lines.push(divider);
|
|
15
|
+
lines.push(`${chalk.gray("Provider:")} ${chalk.yellow(result.provider)}`);
|
|
16
|
+
lines.push(`${chalk.gray("Algorithm:")} ${chalk.white(result.algorithm)}`);
|
|
17
|
+
lines.push(`${chalk.gray("Signature Header:")} ${chalk.white(result.headerName)}`);
|
|
18
|
+
if (result.timestamp) {
|
|
19
|
+
lines.push(`${chalk.gray("Timestamp:")} ${chalk.white(result.timestamp)}`);
|
|
20
|
+
}
|
|
21
|
+
lines.push(divider);
|
|
22
|
+
lines.push(chalk.gray("Raw Body (first 200 chars):"));
|
|
23
|
+
const bodyPreview = truncate(result.rawBody, 200);
|
|
24
|
+
lines.push(chalk.dim(bodyPreview));
|
|
25
|
+
lines.push(divider);
|
|
26
|
+
if (result.signedPayload !== result.rawBody) {
|
|
27
|
+
lines.push(chalk.gray("Signed Payload (first 200 chars):"));
|
|
28
|
+
const payloadPreview = truncate(result.signedPayload, 200);
|
|
29
|
+
lines.push(chalk.dim(payloadPreview));
|
|
30
|
+
lines.push(divider);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
lines.push(chalk.gray("Signed Payload: ") + chalk.dim("<same as raw body>"));
|
|
34
|
+
lines.push(divider);
|
|
35
|
+
}
|
|
36
|
+
lines.push(chalk.gray("Signatures:"));
|
|
37
|
+
const expectedLabel = " Expected: ";
|
|
38
|
+
const computedLabel = " Computed: ";
|
|
39
|
+
if (result.expectedSignature) {
|
|
40
|
+
lines.push(chalk.gray(expectedLabel) + chalk.white(result.expectedSignature));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
lines.push(chalk.gray(expectedLabel) + chalk.red("<missing from request>"));
|
|
44
|
+
}
|
|
45
|
+
if (result.isValid) {
|
|
46
|
+
lines.push(chalk.gray(computedLabel) + chalk.green(result.computedSignature));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
lines.push(chalk.gray(computedLabel) +
|
|
50
|
+
chalk.red(result.computedSignature) +
|
|
51
|
+
chalk.red(" <- MISMATCH"));
|
|
52
|
+
}
|
|
53
|
+
lines.push(divider);
|
|
54
|
+
if (result.error) {
|
|
55
|
+
lines.push(chalk.red(`Error: ${result.error}`));
|
|
56
|
+
}
|
|
57
|
+
else if (result.isValid) {
|
|
58
|
+
lines.push(chalk.green.bold("Status: VALID"));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
lines.push(chalk.red.bold("Status: INVALID"));
|
|
62
|
+
}
|
|
63
|
+
lines.push(divider);
|
|
64
|
+
lines.push("");
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
export function printDebugOutput(result) {
|
|
68
|
+
console.log(formatDebugOutput(result));
|
|
69
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { WebhookProvider } from "../types/index.js";
|
|
2
|
+
export interface DebugVerifyResult {
|
|
3
|
+
provider: WebhookProvider | "unknown";
|
|
4
|
+
rawBody: string;
|
|
5
|
+
signedPayload: string;
|
|
6
|
+
expectedSignature: string | undefined;
|
|
7
|
+
computedSignature: string;
|
|
8
|
+
isValid: boolean;
|
|
9
|
+
algorithm: string;
|
|
10
|
+
headerName: string;
|
|
11
|
+
timestamp?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
type Headers = Record<string, string | string[] | undefined>;
|
|
15
|
+
export declare function debugGitHubVerify(rawBody: string, headers: Headers, secret: string): DebugVerifyResult;
|
|
16
|
+
export declare function debugRagieVerify(rawBody: string, headers: Headers, secret: string): DebugVerifyResult;
|
|
17
|
+
export declare function debugStripeVerify(rawBody: string, headers: Headers, secret: string): DebugVerifyResult;
|
|
18
|
+
export declare function debugSlackVerify(rawBody: string, headers: Headers, secret: string): DebugVerifyResult;
|
|
19
|
+
export declare function debugLinearVerify(rawBody: string, headers: Headers, secret: string): DebugVerifyResult;
|
|
20
|
+
export declare function debugShopifyVerify(rawBody: string, headers: Headers, secret: string): DebugVerifyResult;
|
|
21
|
+
export declare function detectProviderFromHeaders(headers: Headers): WebhookProvider | undefined;
|
|
22
|
+
export declare function getSecretEnvVar(provider: WebhookProvider): string;
|
|
23
|
+
export declare function resolveSecret(cliSecret: string | undefined, provider: WebhookProvider | undefined): string | undefined;
|
|
24
|
+
export declare function debugVerify(provider: WebhookProvider | undefined, rawBody: string, headers: Headers, secret: string): DebugVerifyResult;
|
|
25
|
+
export {};
|