@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,400 @@
|
|
|
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
|
+
verbose;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
const capturesDir = typeof options === "string" ? options : options?.capturesDir;
|
|
18
|
+
this.capturesDir =
|
|
19
|
+
capturesDir || join(homedir(), ".better-webhook", "captures");
|
|
20
|
+
this.enableWebSocket =
|
|
21
|
+
typeof options === "object" ? options?.enableWebSocket !== false : true;
|
|
22
|
+
this.onCapture =
|
|
23
|
+
typeof options === "object" ? options?.onCapture : undefined;
|
|
24
|
+
this.verbose =
|
|
25
|
+
typeof options === "object" ? options?.verbose === true : false;
|
|
26
|
+
if (!existsSync(this.capturesDir)) {
|
|
27
|
+
mkdirSync(this.capturesDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
getCapturesDir() {
|
|
31
|
+
return this.capturesDir;
|
|
32
|
+
}
|
|
33
|
+
async start(port = 3001, host = "0.0.0.0") {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
36
|
+
if (this.enableWebSocket) {
|
|
37
|
+
this.wss = new WebSocketServer({ server: this.server });
|
|
38
|
+
this.wss.on("connection", (ws) => {
|
|
39
|
+
this.clients.add(ws);
|
|
40
|
+
console.log("📡 Dashboard connected via WebSocket");
|
|
41
|
+
ws.on("close", () => {
|
|
42
|
+
this.clients.delete(ws);
|
|
43
|
+
console.log("📡 Dashboard disconnected");
|
|
44
|
+
});
|
|
45
|
+
ws.on("error", (error) => {
|
|
46
|
+
console.error("WebSocket error:", error);
|
|
47
|
+
this.clients.delete(ws);
|
|
48
|
+
});
|
|
49
|
+
this.sendToClient(ws, {
|
|
50
|
+
type: "captures_updated",
|
|
51
|
+
payload: {
|
|
52
|
+
captures: this.listCaptures(),
|
|
53
|
+
count: this.captureCount,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
this.server.on("error", (err) => {
|
|
59
|
+
if (err.code === "EADDRINUSE") {
|
|
60
|
+
reject(new Error(`Port ${port} is already in use`));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
reject(err);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
this.server.listen(port, host, () => {
|
|
67
|
+
const address = this.server?.address();
|
|
68
|
+
const actualPort = typeof address === "object" ? address?.port || port : port;
|
|
69
|
+
console.log(`\n🎣 Webhook Capture Server`);
|
|
70
|
+
console.log(` Listening on http://${host === "0.0.0.0" ? "localhost" : host}:${actualPort}`);
|
|
71
|
+
console.log(` 📁 Captures saved to: ${this.capturesDir}`);
|
|
72
|
+
console.log(` 💡 Send webhooks to any path to capture them`);
|
|
73
|
+
if (this.enableWebSocket) {
|
|
74
|
+
console.log(` 🌐 WebSocket available for real-time updates`);
|
|
75
|
+
}
|
|
76
|
+
console.log(` ⏹️ Press Ctrl+C to stop\n`);
|
|
77
|
+
resolve(actualPort);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async stop() {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
for (const client of this.clients) {
|
|
84
|
+
client.close();
|
|
85
|
+
}
|
|
86
|
+
this.clients.clear();
|
|
87
|
+
if (this.wss) {
|
|
88
|
+
this.wss.close();
|
|
89
|
+
this.wss = null;
|
|
90
|
+
}
|
|
91
|
+
if (this.server) {
|
|
92
|
+
this.server.close(() => {
|
|
93
|
+
console.log("\n🛑 Capture server stopped");
|
|
94
|
+
resolve();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
resolve();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async handleRequest(req, res) {
|
|
103
|
+
if (req.headers.upgrade?.toLowerCase() === "websocket") {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const timestamp = new Date().toISOString();
|
|
107
|
+
const id = randomUUID();
|
|
108
|
+
const url = req.url || "/";
|
|
109
|
+
const hostHeader = req.headers.host;
|
|
110
|
+
const hostValue = typeof hostHeader === "string" ? hostHeader : "";
|
|
111
|
+
const isHostSafe = /^[a-z0-9.-]+(:\d+)?$/i.test(hostValue);
|
|
112
|
+
const baseUrl = isHostSafe ? `http://${hostValue}` : "http://localhost";
|
|
113
|
+
let urlParts;
|
|
114
|
+
try {
|
|
115
|
+
urlParts = new URL(url, baseUrl);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
urlParts = new URL(url, "http://localhost");
|
|
119
|
+
}
|
|
120
|
+
const query = {};
|
|
121
|
+
for (const [key, value] of urlParts.searchParams.entries()) {
|
|
122
|
+
if (query[key]) {
|
|
123
|
+
if (Array.isArray(query[key])) {
|
|
124
|
+
query[key].push(value);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
query[key] = [query[key], value];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
query[key] = value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const chunks = [];
|
|
135
|
+
for await (const chunk of req) {
|
|
136
|
+
chunks.push(chunk);
|
|
137
|
+
}
|
|
138
|
+
const rawBody = Buffer.concat(chunks).toString("utf8");
|
|
139
|
+
let body = null;
|
|
140
|
+
const contentType = req.headers["content-type"] || "";
|
|
141
|
+
if (rawBody) {
|
|
142
|
+
if (contentType.includes("application/json")) {
|
|
143
|
+
try {
|
|
144
|
+
body = JSON.parse(rawBody);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
body = rawBody;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
151
|
+
body = Object.fromEntries(new URLSearchParams(rawBody));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
body = rawBody;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const provider = this.detectProvider(req.headers, body);
|
|
158
|
+
const captured = {
|
|
159
|
+
id,
|
|
160
|
+
timestamp,
|
|
161
|
+
method: (req.method || "GET"),
|
|
162
|
+
url,
|
|
163
|
+
path: urlParts.pathname,
|
|
164
|
+
headers: req.headers,
|
|
165
|
+
body,
|
|
166
|
+
rawBody,
|
|
167
|
+
query,
|
|
168
|
+
provider,
|
|
169
|
+
contentType: contentType || undefined,
|
|
170
|
+
contentLength: rawBody.length,
|
|
171
|
+
};
|
|
172
|
+
const date = new Date(timestamp);
|
|
173
|
+
const dateStr = date.toISOString().split("T")[0];
|
|
174
|
+
const timeStr = date
|
|
175
|
+
.toISOString()
|
|
176
|
+
.split("T")[1]
|
|
177
|
+
?.replace(/[:.]/g, "-")
|
|
178
|
+
.slice(0, 8);
|
|
179
|
+
const filename = `${dateStr}_${timeStr}_${id.slice(0, 8)}.json`;
|
|
180
|
+
const filepath = join(this.capturesDir, filename);
|
|
181
|
+
try {
|
|
182
|
+
writeFileSync(filepath, JSON.stringify(captured, null, 2));
|
|
183
|
+
this.captureCount++;
|
|
184
|
+
const providerStr = provider ? ` [${provider}]` : "";
|
|
185
|
+
console.log(`📦 ${req.method} ${urlParts.pathname}${providerStr} -> ${filename}`);
|
|
186
|
+
if (this.verbose) {
|
|
187
|
+
const headerEntries = Object.entries(req.headers)
|
|
188
|
+
.map(([key, value]) => {
|
|
189
|
+
if (Array.isArray(value)) {
|
|
190
|
+
return `${key}: ${value.join(", ")}`;
|
|
191
|
+
}
|
|
192
|
+
if (value === undefined) {
|
|
193
|
+
return `${key}:`;
|
|
194
|
+
}
|
|
195
|
+
return `${key}: ${value}`;
|
|
196
|
+
})
|
|
197
|
+
.join("\n");
|
|
198
|
+
const method = req.method || "GET";
|
|
199
|
+
const providerLabel = provider || "unknown";
|
|
200
|
+
const contentTypeLabel = contentType || "(none)";
|
|
201
|
+
console.log([
|
|
202
|
+
"[debug] request",
|
|
203
|
+
`method: ${method}`,
|
|
204
|
+
`path: ${urlParts.pathname}`,
|
|
205
|
+
`provider: ${providerLabel}`,
|
|
206
|
+
`content-type: ${contentTypeLabel}`,
|
|
207
|
+
`body-length: ${rawBody.length}`,
|
|
208
|
+
"headers:",
|
|
209
|
+
headerEntries || "(none)",
|
|
210
|
+
"raw-body:",
|
|
211
|
+
rawBody,
|
|
212
|
+
].join("\n"));
|
|
213
|
+
}
|
|
214
|
+
this.onCapture?.({ file: filename, capture: captured });
|
|
215
|
+
if (this.enableWebSocket) {
|
|
216
|
+
this.broadcast({
|
|
217
|
+
type: "capture",
|
|
218
|
+
payload: {
|
|
219
|
+
file: filename,
|
|
220
|
+
capture: captured,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
console.error(`❌ Failed to save capture:`, error);
|
|
227
|
+
}
|
|
228
|
+
res.statusCode = 200;
|
|
229
|
+
res.setHeader("Content-Type", "application/json");
|
|
230
|
+
res.setHeader("X-Capture-Id", id);
|
|
231
|
+
res.end(JSON.stringify({
|
|
232
|
+
success: true,
|
|
233
|
+
message: "Webhook captured successfully",
|
|
234
|
+
id,
|
|
235
|
+
timestamp,
|
|
236
|
+
file: filename,
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
detectProvider(headers, body) {
|
|
240
|
+
if (headers["stripe-signature"]) {
|
|
241
|
+
return "stripe";
|
|
242
|
+
}
|
|
243
|
+
if (headers["x-github-event"] || headers["x-hub-signature-256"]) {
|
|
244
|
+
return "github";
|
|
245
|
+
}
|
|
246
|
+
if (headers["x-signature"]) {
|
|
247
|
+
if (body &&
|
|
248
|
+
typeof body === "object" &&
|
|
249
|
+
"type" in body &&
|
|
250
|
+
"payload" in body &&
|
|
251
|
+
"nonce" in body) {
|
|
252
|
+
return "ragie";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (this.hasStandardWebhookHeaders(headers)) {
|
|
256
|
+
const recallUserAgent = this.headerIncludes(headers["user-agent"], "recall");
|
|
257
|
+
if (recallUserAgent || this.hasRecallStandardWebhookShape(body)) {
|
|
258
|
+
return "recall";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (headers["x-shopify-hmac-sha256"] || headers["x-shopify-topic"]) {
|
|
262
|
+
return "shopify";
|
|
263
|
+
}
|
|
264
|
+
if (headers["x-twilio-signature"]) {
|
|
265
|
+
return "twilio";
|
|
266
|
+
}
|
|
267
|
+
if (headers["x-twilio-email-event-webhook-signature"]) {
|
|
268
|
+
return "sendgrid";
|
|
269
|
+
}
|
|
270
|
+
if (headers["x-slack-signature"]) {
|
|
271
|
+
return "slack";
|
|
272
|
+
}
|
|
273
|
+
if (headers["x-signature-ed25519"]) {
|
|
274
|
+
return "discord";
|
|
275
|
+
}
|
|
276
|
+
if (headers["linear-signature"]) {
|
|
277
|
+
return "linear";
|
|
278
|
+
}
|
|
279
|
+
if (headers["svix-signature"]) {
|
|
280
|
+
if (body &&
|
|
281
|
+
typeof body === "object" &&
|
|
282
|
+
"event" in body &&
|
|
283
|
+
typeof body.event === "string" &&
|
|
284
|
+
body.event.startsWith("bot.")) {
|
|
285
|
+
return "recall";
|
|
286
|
+
}
|
|
287
|
+
return "clerk";
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
broadcast(message) {
|
|
292
|
+
const data = JSON.stringify(message);
|
|
293
|
+
for (const client of this.clients) {
|
|
294
|
+
if (client.readyState === 1) {
|
|
295
|
+
client.send(data);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
hasStandardWebhookHeaders(headers) {
|
|
300
|
+
return Boolean(headers["webhook-signature"] ||
|
|
301
|
+
(headers["webhook-id"] && headers["webhook-timestamp"]));
|
|
302
|
+
}
|
|
303
|
+
hasRecallStandardWebhookShape(body) {
|
|
304
|
+
if (!body || typeof body !== "object") {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
const payload = body;
|
|
308
|
+
const event = payload.event;
|
|
309
|
+
if (typeof event === "string") {
|
|
310
|
+
const recallEventPrefixes = [
|
|
311
|
+
"bot.",
|
|
312
|
+
"transcript.",
|
|
313
|
+
"participant_events.",
|
|
314
|
+
];
|
|
315
|
+
if (recallEventPrefixes.some((prefix) => event.startsWith(prefix))) {
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (this.hasRecallResourceKeys(payload)) {
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
const nestedData = payload.data;
|
|
323
|
+
if (nestedData && typeof nestedData === "object") {
|
|
324
|
+
return this.hasRecallResourceKeys(nestedData);
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
hasRecallResourceKeys(payload) {
|
|
329
|
+
return [
|
|
330
|
+
"bot",
|
|
331
|
+
"realtime_endpoint",
|
|
332
|
+
"participant_events",
|
|
333
|
+
"transcript",
|
|
334
|
+
"recording",
|
|
335
|
+
].some((key) => key in payload);
|
|
336
|
+
}
|
|
337
|
+
headerIncludes(headerValue, searchText) {
|
|
338
|
+
const normalizedSearchText = searchText.toLowerCase();
|
|
339
|
+
if (typeof headerValue === "string") {
|
|
340
|
+
return headerValue.toLowerCase().includes(normalizedSearchText);
|
|
341
|
+
}
|
|
342
|
+
if (Array.isArray(headerValue)) {
|
|
343
|
+
return headerValue.some((value) => value.toLowerCase().includes(normalizedSearchText));
|
|
344
|
+
}
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
sendToClient(client, message) {
|
|
348
|
+
if (client.readyState === 1) {
|
|
349
|
+
client.send(JSON.stringify(message));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
listCaptures(limit = 100) {
|
|
353
|
+
if (!existsSync(this.capturesDir)) {
|
|
354
|
+
return [];
|
|
355
|
+
}
|
|
356
|
+
const files = readdirSync(this.capturesDir)
|
|
357
|
+
.filter((f) => f.endsWith(".json"))
|
|
358
|
+
.sort()
|
|
359
|
+
.reverse()
|
|
360
|
+
.slice(0, limit);
|
|
361
|
+
const captures = [];
|
|
362
|
+
for (const file of files) {
|
|
363
|
+
try {
|
|
364
|
+
const content = readFileSync(join(this.capturesDir, file), "utf-8");
|
|
365
|
+
const capture = JSON.parse(content);
|
|
366
|
+
captures.push({ file, capture });
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return captures;
|
|
372
|
+
}
|
|
373
|
+
getCapture(captureId) {
|
|
374
|
+
const captures = this.listCaptures(1000);
|
|
375
|
+
return (captures.find((c) => c.capture.id === captureId || c.file.includes(captureId)) || null);
|
|
376
|
+
}
|
|
377
|
+
deleteCapture(captureId) {
|
|
378
|
+
const capture = this.getCapture(captureId);
|
|
379
|
+
if (!capture) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
unlinkSync(join(this.capturesDir, capture.file));
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
getClientCount() {
|
|
391
|
+
return this.clients.size;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
let instance = null;
|
|
395
|
+
export function getCaptureServer(capturesDir) {
|
|
396
|
+
if (!instance) {
|
|
397
|
+
instance = new CaptureServer(capturesDir);
|
|
398
|
+
}
|
|
399
|
+
return instance;
|
|
400
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { request } from "undici";
|
|
6
|
+
import { CaptureServer } from "./capture-server.js";
|
|
7
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
8
|
+
setTimeout(resolve, ms);
|
|
9
|
+
});
|
|
10
|
+
describe("CaptureServer provider detection", () => {
|
|
11
|
+
let capturesDir;
|
|
12
|
+
let server;
|
|
13
|
+
let port;
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
capturesDir = join(tmpdir(), `better-webhook-capture-test-${Date.now()}`);
|
|
16
|
+
mkdirSync(capturesDir, { recursive: true });
|
|
17
|
+
server = new CaptureServer({ capturesDir, enableWebSocket: false });
|
|
18
|
+
port = await server.start(0, "127.0.0.1");
|
|
19
|
+
});
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await server.stop();
|
|
22
|
+
rmSync(capturesDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
async function waitForCapturedProvider() {
|
|
25
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
26
|
+
const captures = server.listCaptures(1);
|
|
27
|
+
const provider = captures[0]?.capture.provider;
|
|
28
|
+
if (provider) {
|
|
29
|
+
return provider;
|
|
30
|
+
}
|
|
31
|
+
await sleep(25);
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
it("detects Recall from webhook verification headers", async () => {
|
|
36
|
+
const response = await request(`http://127.0.0.1:${port}/webhooks/recall`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"content-type": "application/json",
|
|
40
|
+
"webhook-id": "msg_test_123",
|
|
41
|
+
"webhook-timestamp": "1731705121",
|
|
42
|
+
"webhook-signature": "v1,abc123",
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
event: "transcript.data",
|
|
46
|
+
data: { data: { words: [] } },
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
expect(response.statusCode).toBe(200);
|
|
50
|
+
expect(await waitForCapturedProvider()).toBe("recall");
|
|
51
|
+
});
|
|
52
|
+
it("does not misclassify generic Standard Webhooks payloads as Recall", async () => {
|
|
53
|
+
const response = await request(`http://127.0.0.1:${port}/webhooks/generic`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
"webhook-id": "msg_test_generic_123",
|
|
58
|
+
"webhook-timestamp": "1731705121",
|
|
59
|
+
"webhook-signature": "v1,abc123",
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
event: "user.created",
|
|
63
|
+
data: { id: "usr_123" },
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
expect(response.statusCode).toBe(200);
|
|
67
|
+
expect(await waitForCapturedProvider()).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
it("detects Recall from svix headers when event starts with bot.", async () => {
|
|
70
|
+
const response = await request(`http://127.0.0.1:${port}/webhooks/recall`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: {
|
|
73
|
+
"content-type": "application/json",
|
|
74
|
+
"svix-id": "msg_test_234",
|
|
75
|
+
"svix-timestamp": "1731705121",
|
|
76
|
+
"svix-signature": "v1,abc123",
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
event: "bot.done",
|
|
80
|
+
data: { data: { code: "done", sub_code: null, updated_at: "now" } },
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
expect(response.statusCode).toBe(200);
|
|
84
|
+
expect(await waitForCapturedProvider()).toBe("recall");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function resolveRuntimePackageVersion(runtimeDir: string): string | undefined;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { findCliPackageRoot } from "./runtime-paths.js";
|
|
4
|
+
function readPackageVersion(packageJsonPath) {
|
|
5
|
+
try {
|
|
6
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, { encoding: "utf8" }));
|
|
7
|
+
return typeof packageJson.version === "string" && packageJson.version
|
|
8
|
+
? packageJson.version
|
|
9
|
+
: undefined;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function resolveRuntimePackageVersion(runtimeDir) {
|
|
16
|
+
const searchDirs = [runtimeDir, path.resolve(runtimeDir, "..")];
|
|
17
|
+
const visitedRoots = new Set();
|
|
18
|
+
for (const searchDir of searchDirs) {
|
|
19
|
+
const cliPackageRoot = findCliPackageRoot(searchDir);
|
|
20
|
+
if (!cliPackageRoot || visitedRoots.has(cliPackageRoot)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
visitedRoots.add(cliPackageRoot);
|
|
24
|
+
const version = readPackageVersion(path.join(cliPackageRoot, "package.json"));
|
|
25
|
+
if (version) {
|
|
26
|
+
return version;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { resolveRuntimePackageVersion } from "./cli-version.js";
|
|
6
|
+
const tempDirs = [];
|
|
7
|
+
function createTempDir(prefix) {
|
|
8
|
+
const tempDir = mkdtempSync(path.join(tmpdir(), prefix));
|
|
9
|
+
tempDirs.push(tempDir);
|
|
10
|
+
return tempDir;
|
|
11
|
+
}
|
|
12
|
+
function writePackageJson(dirPath, metadata) {
|
|
13
|
+
mkdirSync(dirPath, { recursive: true });
|
|
14
|
+
writeFileSync(path.join(dirPath, "package.json"), JSON.stringify(metadata));
|
|
15
|
+
}
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const tempDir of tempDirs) {
|
|
18
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
tempDirs.length = 0;
|
|
21
|
+
});
|
|
22
|
+
describe("resolveRuntimePackageVersion", () => {
|
|
23
|
+
it("returns CLI version from the resolved package root", () => {
|
|
24
|
+
const tempDir = createTempDir("better-webhook-cli-version-");
|
|
25
|
+
const cliRoot = path.join(tempDir, "node_modules", "@better-webhook", "cli");
|
|
26
|
+
const runtimeDir = path.join(cliRoot, "dist", "core");
|
|
27
|
+
writePackageJson(cliRoot, {
|
|
28
|
+
name: "@better-webhook/cli",
|
|
29
|
+
version: "3.8.0",
|
|
30
|
+
});
|
|
31
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
32
|
+
expect(resolveRuntimePackageVersion(runtimeDir)).toBe("3.8.0");
|
|
33
|
+
});
|
|
34
|
+
it("returns undefined when package roots do not match CLI names", () => {
|
|
35
|
+
const tempDir = createTempDir("better-webhook-cli-version-");
|
|
36
|
+
const runtimeRoot = path.join(tempDir, "runtime");
|
|
37
|
+
const runtimeDir = path.join(runtimeRoot, "dist", "core");
|
|
38
|
+
writePackageJson(runtimeRoot, { name: "consumer-app", version: "1.0.0" });
|
|
39
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
40
|
+
expect(resolveRuntimePackageVersion(runtimeDir)).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -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;
|