hookdash 0.1.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/LICENSE +21 -0
- package/README.md +385 -0
- package/dashboard/dist/assets/index-BDjBwO-x.css +1 -0
- package/dashboard/dist/assets/index-DbQJ42V1.js +51 -0
- package/dashboard/dist/index.html +18 -0
- package/dist/index.js +1569 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1569 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/db/schema.ts
|
|
13
|
+
var SCHEMA_SQL, CURRENT_SCHEMA_VERSION;
|
|
14
|
+
var init_schema = __esm({
|
|
15
|
+
"src/db/schema.ts"() {
|
|
16
|
+
"use strict";
|
|
17
|
+
SCHEMA_SQL = `
|
|
18
|
+
-- Webhook sources / providers
|
|
19
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
name TEXT NOT NULL UNIQUE,
|
|
22
|
+
provider TEXT NOT NULL DEFAULT 'generic',
|
|
23
|
+
signing_secret TEXT,
|
|
24
|
+
active INTEGER DEFAULT 1,
|
|
25
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
-- Forwarding endpoints
|
|
29
|
+
CREATE TABLE IF NOT EXISTS endpoints (
|
|
30
|
+
id TEXT PRIMARY KEY,
|
|
31
|
+
url TEXT NOT NULL,
|
|
32
|
+
source_id TEXT REFERENCES sources(id) ON DELETE SET NULL,
|
|
33
|
+
event_filter TEXT,
|
|
34
|
+
headers TEXT,
|
|
35
|
+
timeout_ms INTEGER DEFAULT 30000,
|
|
36
|
+
max_retries INTEGER DEFAULT 8,
|
|
37
|
+
active INTEGER DEFAULT 1,
|
|
38
|
+
circuit_state TEXT DEFAULT 'closed',
|
|
39
|
+
circuit_failure_count INTEGER DEFAULT 0,
|
|
40
|
+
circuit_last_failure_at TEXT,
|
|
41
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
-- Incoming webhook events
|
|
45
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
source_id TEXT NOT NULL REFERENCES sources(id),
|
|
48
|
+
event_type TEXT,
|
|
49
|
+
headers TEXT NOT NULL,
|
|
50
|
+
body TEXT NOT NULL,
|
|
51
|
+
content_type TEXT,
|
|
52
|
+
signature_valid INTEGER,
|
|
53
|
+
received_at TEXT DEFAULT (datetime('now'))
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_events_received ON events(received_at);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_events_source ON events(source_id);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
|
|
59
|
+
|
|
60
|
+
-- Delivery attempts
|
|
61
|
+
CREATE TABLE IF NOT EXISTS deliveries (
|
|
62
|
+
id TEXT PRIMARY KEY,
|
|
63
|
+
event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
|
64
|
+
endpoint_id TEXT NOT NULL REFERENCES endpoints(id) ON DELETE CASCADE,
|
|
65
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
66
|
+
attempts INTEGER DEFAULT 0,
|
|
67
|
+
next_retry_at TEXT,
|
|
68
|
+
last_status_code INTEGER,
|
|
69
|
+
last_response_body TEXT,
|
|
70
|
+
last_error TEXT,
|
|
71
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
72
|
+
completed_at TEXT
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_status ON deliveries(status);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_next_retry ON deliveries(next_retry_at);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_event ON deliveries(event_id);
|
|
78
|
+
|
|
79
|
+
-- Schema version tracking
|
|
80
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
81
|
+
version INTEGER PRIMARY KEY,
|
|
82
|
+
applied_at TEXT DEFAULT (datetime('now'))
|
|
83
|
+
);
|
|
84
|
+
`;
|
|
85
|
+
CURRENT_SCHEMA_VERSION = 1;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// src/db/connection.ts
|
|
90
|
+
import Database from "better-sqlite3";
|
|
91
|
+
import { resolve as resolve2 } from "path";
|
|
92
|
+
import { mkdirSync } from "fs";
|
|
93
|
+
import { dirname } from "path";
|
|
94
|
+
function getDatabase(config) {
|
|
95
|
+
if (db) return db;
|
|
96
|
+
const dbPath = config?.database.path ? resolve2(config.database.path) : resolve2("./hookdash.db");
|
|
97
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
98
|
+
db = new Database(dbPath);
|
|
99
|
+
db.pragma("journal_mode = WAL");
|
|
100
|
+
db.pragma("synchronous = NORMAL");
|
|
101
|
+
db.pragma("foreign_keys = ON");
|
|
102
|
+
db.pragma("busy_timeout = 5000");
|
|
103
|
+
db.pragma("cache_size = -20000");
|
|
104
|
+
applySchema(db);
|
|
105
|
+
console.log(`[hookdash] Database ready at ${dbPath}`);
|
|
106
|
+
return db;
|
|
107
|
+
}
|
|
108
|
+
function applySchema(database) {
|
|
109
|
+
database.exec(SCHEMA_SQL);
|
|
110
|
+
const row = database.prepare(
|
|
111
|
+
"SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
|
|
112
|
+
).get();
|
|
113
|
+
const currentVersion = row?.version ?? 0;
|
|
114
|
+
if (currentVersion < CURRENT_SCHEMA_VERSION) {
|
|
115
|
+
runMigrations(database, currentVersion, CURRENT_SCHEMA_VERSION);
|
|
116
|
+
database.prepare(
|
|
117
|
+
"INSERT OR REPLACE INTO schema_version (version) VALUES (?)"
|
|
118
|
+
).run(CURRENT_SCHEMA_VERSION);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function runMigrations(database, _fromVersion, _toVersion) {
|
|
122
|
+
}
|
|
123
|
+
function closeDatabase() {
|
|
124
|
+
if (db) {
|
|
125
|
+
db.close();
|
|
126
|
+
db = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function seedFromConfig(config) {
|
|
130
|
+
const database = getDatabase(config);
|
|
131
|
+
const upsertSource = database.prepare(`
|
|
132
|
+
INSERT INTO sources (id, name, provider, signing_secret, active)
|
|
133
|
+
VALUES (lower(hex(randomblob(8))), ?, ?, ?, 1)
|
|
134
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
135
|
+
provider = excluded.provider,
|
|
136
|
+
signing_secret = excluded.signing_secret,
|
|
137
|
+
active = 1
|
|
138
|
+
`);
|
|
139
|
+
const getSource = database.prepare("SELECT id FROM sources WHERE name = ?");
|
|
140
|
+
const upsertEndpoint = database.prepare(`
|
|
141
|
+
INSERT INTO endpoints (id, url, source_id, event_filter, headers, timeout_ms, max_retries)
|
|
142
|
+
VALUES (lower(hex(randomblob(8))), ?, ?, ?, ?, ?, ?)
|
|
143
|
+
ON CONFLICT DO NOTHING
|
|
144
|
+
`);
|
|
145
|
+
const checkEndpoint = database.prepare(
|
|
146
|
+
"SELECT id FROM endpoints WHERE url = ? AND source_id = ?"
|
|
147
|
+
);
|
|
148
|
+
const seedTransaction = database.transaction(() => {
|
|
149
|
+
for (const source of config.sources) {
|
|
150
|
+
upsertSource.run(source.name, source.provider, source.signing_secret ?? null);
|
|
151
|
+
const sourceRow = getSource.get(source.name);
|
|
152
|
+
for (const endpoint of source.endpoints) {
|
|
153
|
+
const existing = checkEndpoint.get(endpoint.url, sourceRow.id);
|
|
154
|
+
if (!existing) {
|
|
155
|
+
upsertEndpoint.run(
|
|
156
|
+
endpoint.url,
|
|
157
|
+
sourceRow.id,
|
|
158
|
+
endpoint.events ? JSON.stringify(endpoint.events) : null,
|
|
159
|
+
endpoint.headers ? JSON.stringify(endpoint.headers) : null,
|
|
160
|
+
endpoint.timeout_ms ?? config.delivery.default_timeout,
|
|
161
|
+
endpoint.max_retries ?? config.delivery.default_max_retries
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
seedTransaction();
|
|
168
|
+
console.log(`[hookdash] Seeded ${config.sources.length} source(s) from config`);
|
|
169
|
+
}
|
|
170
|
+
var db;
|
|
171
|
+
var init_connection = __esm({
|
|
172
|
+
"src/db/connection.ts"() {
|
|
173
|
+
"use strict";
|
|
174
|
+
init_schema();
|
|
175
|
+
db = null;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// src/providers/stripe.ts
|
|
180
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
181
|
+
var TIMESTAMP_TOLERANCE_SEC, stripeProvider;
|
|
182
|
+
var init_stripe = __esm({
|
|
183
|
+
"src/providers/stripe.ts"() {
|
|
184
|
+
"use strict";
|
|
185
|
+
TIMESTAMP_TOLERANCE_SEC = 300;
|
|
186
|
+
stripeProvider = {
|
|
187
|
+
name: "stripe",
|
|
188
|
+
verify(secret, headers, rawBody) {
|
|
189
|
+
const sigHeader = headers["stripe-signature"];
|
|
190
|
+
if (!sigHeader) return false;
|
|
191
|
+
const parts = /* @__PURE__ */ new Map();
|
|
192
|
+
for (const pair of sigHeader.split(",")) {
|
|
193
|
+
const idx = pair.indexOf("=");
|
|
194
|
+
if (idx === -1) continue;
|
|
195
|
+
parts.set(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
|
|
196
|
+
}
|
|
197
|
+
const timestamp = parts.get("t");
|
|
198
|
+
const signature = parts.get("v1");
|
|
199
|
+
if (!timestamp || !signature) return false;
|
|
200
|
+
const ts = parseInt(timestamp, 10);
|
|
201
|
+
if (Number.isNaN(ts)) return false;
|
|
202
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
203
|
+
if (Math.abs(now - ts) > TIMESTAMP_TOLERANCE_SEC) return false;
|
|
204
|
+
const signedPayload = `${timestamp}.${rawBody.toString("utf8")}`;
|
|
205
|
+
const expectedSig = createHmac("sha256", secret).update(signedPayload).digest("hex");
|
|
206
|
+
const sigBuf = Buffer.from(signature, "hex");
|
|
207
|
+
const expectedBuf = Buffer.from(expectedSig, "hex");
|
|
208
|
+
if (sigBuf.length !== expectedBuf.length) return false;
|
|
209
|
+
return timingSafeEqual(sigBuf, expectedBuf);
|
|
210
|
+
},
|
|
211
|
+
extractEventType(_headers, body) {
|
|
212
|
+
if (body && typeof body === "object" && "type" in body) {
|
|
213
|
+
const t = body.type;
|
|
214
|
+
return typeof t === "string" ? t : null;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// src/providers/github.ts
|
|
223
|
+
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
224
|
+
var githubProvider;
|
|
225
|
+
var init_github = __esm({
|
|
226
|
+
"src/providers/github.ts"() {
|
|
227
|
+
"use strict";
|
|
228
|
+
githubProvider = {
|
|
229
|
+
name: "github",
|
|
230
|
+
verify(secret, headers, rawBody) {
|
|
231
|
+
const sigHeader = headers["x-hub-signature-256"];
|
|
232
|
+
if (!sigHeader) return false;
|
|
233
|
+
const prefix = "sha256=";
|
|
234
|
+
if (!sigHeader.startsWith(prefix)) return false;
|
|
235
|
+
const signature = sigHeader.slice(prefix.length);
|
|
236
|
+
const expectedSig = createHmac2("sha256", secret).update(rawBody).digest("hex");
|
|
237
|
+
const sigBuf = Buffer.from(signature, "hex");
|
|
238
|
+
const expectedBuf = Buffer.from(expectedSig, "hex");
|
|
239
|
+
if (sigBuf.length !== expectedBuf.length) return false;
|
|
240
|
+
return timingSafeEqual2(sigBuf, expectedBuf);
|
|
241
|
+
},
|
|
242
|
+
extractEventType(headers, _body) {
|
|
243
|
+
return headers["x-github-event"] ?? null;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// src/providers/twilio.ts
|
|
250
|
+
import { createHmac as createHmac3, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
251
|
+
var twilioProvider;
|
|
252
|
+
var init_twilio = __esm({
|
|
253
|
+
"src/providers/twilio.ts"() {
|
|
254
|
+
"use strict";
|
|
255
|
+
twilioProvider = {
|
|
256
|
+
name: "twilio",
|
|
257
|
+
verify(secret, headers, rawBody) {
|
|
258
|
+
const sigHeader = headers["x-twilio-signature"];
|
|
259
|
+
if (!sigHeader) return false;
|
|
260
|
+
const expectedSig = createHmac3("sha1", secret).update(rawBody).digest("base64");
|
|
261
|
+
const sigBuf = Buffer.from(sigHeader, "base64");
|
|
262
|
+
const expectedBuf = Buffer.from(expectedSig, "base64");
|
|
263
|
+
if (sigBuf.length !== expectedBuf.length) return false;
|
|
264
|
+
return timingSafeEqual3(sigBuf, expectedBuf);
|
|
265
|
+
},
|
|
266
|
+
extractEventType(_headers, body) {
|
|
267
|
+
if (body && typeof body === "object") {
|
|
268
|
+
const b = body;
|
|
269
|
+
if (typeof b.EventType === "string") return b.EventType;
|
|
270
|
+
if (typeof b.event_type === "string") return b.event_type;
|
|
271
|
+
if (typeof b.type === "string") return b.type;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// src/providers/shopify.ts
|
|
280
|
+
import { createHmac as createHmac4, timingSafeEqual as timingSafeEqual4 } from "crypto";
|
|
281
|
+
var shopifyProvider;
|
|
282
|
+
var init_shopify = __esm({
|
|
283
|
+
"src/providers/shopify.ts"() {
|
|
284
|
+
"use strict";
|
|
285
|
+
shopifyProvider = {
|
|
286
|
+
name: "shopify",
|
|
287
|
+
verify(secret, headers, rawBody) {
|
|
288
|
+
const sigHeader = headers["x-shopify-hmac-sha256"];
|
|
289
|
+
if (!sigHeader) return false;
|
|
290
|
+
const expectedSig = createHmac4("sha256", secret).update(rawBody).digest("base64");
|
|
291
|
+
const sigBuf = Buffer.from(sigHeader, "base64");
|
|
292
|
+
const expectedBuf = Buffer.from(expectedSig, "base64");
|
|
293
|
+
if (sigBuf.length !== expectedBuf.length) return false;
|
|
294
|
+
return timingSafeEqual4(sigBuf, expectedBuf);
|
|
295
|
+
},
|
|
296
|
+
extractEventType(headers, _body) {
|
|
297
|
+
return headers["x-shopify-topic"] ?? null;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// src/providers/generic.ts
|
|
304
|
+
import { createHmac as createHmac5, timingSafeEqual as timingSafeEqual5 } from "crypto";
|
|
305
|
+
var genericProvider;
|
|
306
|
+
var init_generic = __esm({
|
|
307
|
+
"src/providers/generic.ts"() {
|
|
308
|
+
"use strict";
|
|
309
|
+
genericProvider = {
|
|
310
|
+
name: "generic",
|
|
311
|
+
verify(secret, headers, rawBody) {
|
|
312
|
+
const sigHeader = headers["x-webhook-signature"] ?? headers["x-signature"];
|
|
313
|
+
if (!sigHeader) return false;
|
|
314
|
+
const expectedSig = createHmac5("sha256", secret).update(rawBody).digest("hex");
|
|
315
|
+
const sigBuf = Buffer.from(sigHeader, "hex");
|
|
316
|
+
const expectedBuf = Buffer.from(expectedSig, "hex");
|
|
317
|
+
if (sigBuf.length !== expectedBuf.length) return false;
|
|
318
|
+
return timingSafeEqual5(sigBuf, expectedBuf);
|
|
319
|
+
},
|
|
320
|
+
extractEventType(_headers, body) {
|
|
321
|
+
if (body && typeof body === "object") {
|
|
322
|
+
const b = body;
|
|
323
|
+
if (typeof b.event === "string") return b.event;
|
|
324
|
+
if (typeof b.type === "string") return b.type;
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// src/providers/registry.ts
|
|
333
|
+
function register(provider) {
|
|
334
|
+
providers.set(provider.name, provider);
|
|
335
|
+
}
|
|
336
|
+
function getProvider(name) {
|
|
337
|
+
const provider = providers.get(name);
|
|
338
|
+
if (!provider) {
|
|
339
|
+
const available = [...providers.keys()].join(", ");
|
|
340
|
+
throw new Error(
|
|
341
|
+
`Unknown webhook provider "${name}". Available providers: ${available}`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
return provider;
|
|
345
|
+
}
|
|
346
|
+
var providers;
|
|
347
|
+
var init_registry = __esm({
|
|
348
|
+
"src/providers/registry.ts"() {
|
|
349
|
+
"use strict";
|
|
350
|
+
init_stripe();
|
|
351
|
+
init_github();
|
|
352
|
+
init_twilio();
|
|
353
|
+
init_shopify();
|
|
354
|
+
init_generic();
|
|
355
|
+
providers = /* @__PURE__ */ new Map();
|
|
356
|
+
register(stripeProvider);
|
|
357
|
+
register(githubProvider);
|
|
358
|
+
register(twilioProvider);
|
|
359
|
+
register(shopifyProvider);
|
|
360
|
+
register(genericProvider);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// src/ingestion/parser.ts
|
|
365
|
+
function normalizeHeaders(headers) {
|
|
366
|
+
const normalized = {};
|
|
367
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
368
|
+
if (value === void 0) continue;
|
|
369
|
+
normalized[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
|
|
370
|
+
}
|
|
371
|
+
return normalized;
|
|
372
|
+
}
|
|
373
|
+
function parseBodyEventType(rawBody) {
|
|
374
|
+
let parsed = null;
|
|
375
|
+
let eventType = null;
|
|
376
|
+
try {
|
|
377
|
+
parsed = JSON.parse(rawBody.toString("utf8"));
|
|
378
|
+
} catch {
|
|
379
|
+
return { parsed: null, eventType: null };
|
|
380
|
+
}
|
|
381
|
+
if (parsed && typeof parsed === "object") {
|
|
382
|
+
const obj = parsed;
|
|
383
|
+
for (const field of ["event", "type", "event_type", "eventType", "action"]) {
|
|
384
|
+
if (typeof obj[field] === "string") {
|
|
385
|
+
eventType = obj[field];
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { parsed, eventType };
|
|
391
|
+
}
|
|
392
|
+
function matchesEventFilter(eventType, filterPatterns) {
|
|
393
|
+
if (!filterPatterns || filterPatterns.length === 0) return true;
|
|
394
|
+
if (!eventType) return false;
|
|
395
|
+
return filterPatterns.some((pattern) => globMatch(pattern, eventType));
|
|
396
|
+
}
|
|
397
|
+
function globMatch(pattern, value) {
|
|
398
|
+
if (pattern === "*" || pattern === "**") return true;
|
|
399
|
+
let regexStr = "";
|
|
400
|
+
let i = 0;
|
|
401
|
+
while (i < pattern.length) {
|
|
402
|
+
const char = pattern[i];
|
|
403
|
+
if (char === "*") {
|
|
404
|
+
if (pattern[i + 1] === "*") {
|
|
405
|
+
regexStr += ".*";
|
|
406
|
+
i += 2;
|
|
407
|
+
} else {
|
|
408
|
+
regexStr += "[^.]*";
|
|
409
|
+
i += 1;
|
|
410
|
+
}
|
|
411
|
+
} else if (char === "?") {
|
|
412
|
+
regexStr += "[^.]";
|
|
413
|
+
i += 1;
|
|
414
|
+
} else if (".+^${}()|[]\\".includes(char)) {
|
|
415
|
+
regexStr += `\\${char}`;
|
|
416
|
+
i += 1;
|
|
417
|
+
} else {
|
|
418
|
+
regexStr += char;
|
|
419
|
+
i += 1;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
423
|
+
return regex.test(value);
|
|
424
|
+
}
|
|
425
|
+
var init_parser = __esm({
|
|
426
|
+
"src/ingestion/parser.ts"() {
|
|
427
|
+
"use strict";
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// src/delivery/circuit-breaker.ts
|
|
432
|
+
var CircuitBreaker;
|
|
433
|
+
var init_circuit_breaker = __esm({
|
|
434
|
+
"src/delivery/circuit-breaker.ts"() {
|
|
435
|
+
"use strict";
|
|
436
|
+
CircuitBreaker = class {
|
|
437
|
+
db;
|
|
438
|
+
failureThreshold = 5;
|
|
439
|
+
cooldownMs = 6e4;
|
|
440
|
+
// 60 seconds
|
|
441
|
+
constructor(db2) {
|
|
442
|
+
this.db = db2;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Check if we can deliver to this endpoint.
|
|
446
|
+
* If state is open, checks if cooldown has expired to transition to half-open.
|
|
447
|
+
*/
|
|
448
|
+
canDeliver(endpointId) {
|
|
449
|
+
const row = this.db.prepare(
|
|
450
|
+
"SELECT circuit_state, circuit_last_failure_at FROM endpoints WHERE id = ?"
|
|
451
|
+
).get(endpointId);
|
|
452
|
+
if (!row) return false;
|
|
453
|
+
if (row.circuit_state === "closed" || row.circuit_state === "half-open") {
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
if (row.circuit_state === "open" && row.circuit_last_failure_at) {
|
|
457
|
+
const lastFailure = new Date(row.circuit_last_failure_at).getTime();
|
|
458
|
+
const elapsed = Date.now() - lastFailure;
|
|
459
|
+
if (elapsed >= this.cooldownMs) {
|
|
460
|
+
this.db.prepare(
|
|
461
|
+
"UPDATE endpoints SET circuit_state = 'half-open' WHERE id = ?"
|
|
462
|
+
).run(endpointId);
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Reset circuit to closed on successful delivery.
|
|
470
|
+
*/
|
|
471
|
+
recordSuccess(endpointId) {
|
|
472
|
+
this.db.prepare(`
|
|
473
|
+
UPDATE endpoints
|
|
474
|
+
SET circuit_state = 'closed',
|
|
475
|
+
circuit_failure_count = 0,
|
|
476
|
+
circuit_last_failure_at = NULL
|
|
477
|
+
WHERE id = ?
|
|
478
|
+
`).run(endpointId);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Record failure. If consecutive failures exceed threshold, open the circuit.
|
|
482
|
+
*/
|
|
483
|
+
recordFailure(endpointId) {
|
|
484
|
+
const row = this.db.prepare(
|
|
485
|
+
"SELECT circuit_failure_count, circuit_state FROM endpoints WHERE id = ?"
|
|
486
|
+
).get(endpointId);
|
|
487
|
+
if (!row) return;
|
|
488
|
+
const newFailureCount = row.circuit_failure_count + 1;
|
|
489
|
+
const nowStr = (/* @__PURE__ */ new Date()).toISOString();
|
|
490
|
+
if (newFailureCount >= this.failureThreshold || row.circuit_state === "half-open") {
|
|
491
|
+
this.db.prepare(`
|
|
492
|
+
UPDATE endpoints
|
|
493
|
+
SET circuit_state = 'open',
|
|
494
|
+
circuit_failure_count = ?,
|
|
495
|
+
circuit_last_failure_at = ?
|
|
496
|
+
WHERE id = ?
|
|
497
|
+
`).run(newFailureCount, nowStr, endpointId);
|
|
498
|
+
} else {
|
|
499
|
+
this.db.prepare(`
|
|
500
|
+
UPDATE endpoints
|
|
501
|
+
SET circuit_failure_count = ?,
|
|
502
|
+
circuit_last_failure_at = ?
|
|
503
|
+
WHERE id = ?
|
|
504
|
+
`).run(newFailureCount, nowStr, endpointId);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// src/delivery/retry.ts
|
|
512
|
+
function getNextRetryDelay(attempt) {
|
|
513
|
+
const BASE_DELAY = 1e3;
|
|
514
|
+
const MAX_DELAY = 36e5;
|
|
515
|
+
const JITTER_FACTOR = 0.25;
|
|
516
|
+
const exponential = BASE_DELAY * Math.pow(2, attempt);
|
|
517
|
+
const capped = Math.min(exponential, MAX_DELAY);
|
|
518
|
+
const jitter = capped * JITTER_FACTOR * Math.random();
|
|
519
|
+
return capped + jitter;
|
|
520
|
+
}
|
|
521
|
+
function shouldRetry(statusCode) {
|
|
522
|
+
if (statusCode === 0 || statusCode === -1) return true;
|
|
523
|
+
if (statusCode === 429) return true;
|
|
524
|
+
if (statusCode >= 500 && statusCode < 600) return true;
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
var init_retry = __esm({
|
|
528
|
+
"src/delivery/retry.ts"() {
|
|
529
|
+
"use strict";
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// src/delivery/worker.ts
|
|
534
|
+
import { EventEmitter } from "events";
|
|
535
|
+
var deliveryEvents, DeliveryWorker;
|
|
536
|
+
var init_worker = __esm({
|
|
537
|
+
"src/delivery/worker.ts"() {
|
|
538
|
+
"use strict";
|
|
539
|
+
init_connection();
|
|
540
|
+
init_circuit_breaker();
|
|
541
|
+
init_retry();
|
|
542
|
+
deliveryEvents = new EventEmitter();
|
|
543
|
+
DeliveryWorker = class {
|
|
544
|
+
db;
|
|
545
|
+
intervalId = null;
|
|
546
|
+
pollInterval;
|
|
547
|
+
defaultTimeout;
|
|
548
|
+
circuitBreaker;
|
|
549
|
+
isProcessing = false;
|
|
550
|
+
constructor(config) {
|
|
551
|
+
this.db = getDatabase(config);
|
|
552
|
+
this.pollInterval = config.delivery.poll_interval;
|
|
553
|
+
this.defaultTimeout = config.delivery.default_timeout;
|
|
554
|
+
this.circuitBreaker = new CircuitBreaker(this.db);
|
|
555
|
+
}
|
|
556
|
+
start() {
|
|
557
|
+
if (this.intervalId) return;
|
|
558
|
+
this.intervalId = setInterval(async () => {
|
|
559
|
+
if (this.isProcessing) return;
|
|
560
|
+
this.isProcessing = true;
|
|
561
|
+
try {
|
|
562
|
+
await this.processQueue();
|
|
563
|
+
} catch (err) {
|
|
564
|
+
console.error("[hookdash-worker] Error processing queue:", err);
|
|
565
|
+
} finally {
|
|
566
|
+
this.isProcessing = false;
|
|
567
|
+
}
|
|
568
|
+
}, this.pollInterval);
|
|
569
|
+
console.log(`[hookdash-worker] Started queue worker (poll: ${this.pollInterval}ms)`);
|
|
570
|
+
}
|
|
571
|
+
stop() {
|
|
572
|
+
if (this.intervalId) {
|
|
573
|
+
clearInterval(this.intervalId);
|
|
574
|
+
this.intervalId = null;
|
|
575
|
+
console.log("[hookdash-worker] Stopped queue worker");
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
async processQueue() {
|
|
579
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
580
|
+
const dueDeliveries = this.db.prepare(`
|
|
581
|
+
SELECT d.*, e.url as endpoint_url, e.headers as endpoint_headers, e.timeout_ms as endpoint_timeout,
|
|
582
|
+
e.max_retries as endpoint_max_retries, ev.body as event_body, ev.headers as event_headers,
|
|
583
|
+
ev.content_type as event_content_type
|
|
584
|
+
FROM deliveries d
|
|
585
|
+
JOIN endpoints e ON d.endpoint_id = e.id
|
|
586
|
+
JOIN events ev ON d.event_id = ev.id
|
|
587
|
+
WHERE (d.status = 'pending' OR (d.status = 'retrying' AND d.next_retry_at <= ?))
|
|
588
|
+
LIMIT 10
|
|
589
|
+
`).all(now);
|
|
590
|
+
if (dueDeliveries.length === 0) return;
|
|
591
|
+
for (const d of dueDeliveries) {
|
|
592
|
+
if (!this.circuitBreaker.canDeliver(d.endpoint_id)) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
await this.deliver(d);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async deliver(d) {
|
|
599
|
+
const endpointHeaders = d.endpoint_headers ? JSON.parse(d.endpoint_headers) : {};
|
|
600
|
+
const eventHeaders = d.event_headers ? JSON.parse(d.event_headers) : {};
|
|
601
|
+
const headers = {
|
|
602
|
+
"content-type": d.event_content_type || "application/json",
|
|
603
|
+
...eventHeaders,
|
|
604
|
+
...endpointHeaders,
|
|
605
|
+
"x-hookdash-delivery-id": d.id,
|
|
606
|
+
"x-hookdash-event-id": d.event_id,
|
|
607
|
+
"x-hookdash-attempt": (d.attempts + 1).toString()
|
|
608
|
+
};
|
|
609
|
+
delete headers["connection"];
|
|
610
|
+
delete headers["host"];
|
|
611
|
+
delete headers["content-length"];
|
|
612
|
+
const timeout = d.endpoint_timeout || this.defaultTimeout;
|
|
613
|
+
const controller = new AbortController();
|
|
614
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
615
|
+
const startTime = Date.now();
|
|
616
|
+
let status = "failed";
|
|
617
|
+
let statusCode = null;
|
|
618
|
+
let responseBody = "";
|
|
619
|
+
let errorMsg = null;
|
|
620
|
+
try {
|
|
621
|
+
const response = await fetch(d.endpoint_url, {
|
|
622
|
+
method: "POST",
|
|
623
|
+
headers,
|
|
624
|
+
body: d.event_body,
|
|
625
|
+
signal: controller.signal
|
|
626
|
+
});
|
|
627
|
+
statusCode = response.status;
|
|
628
|
+
responseBody = await response.text();
|
|
629
|
+
if (response.ok) {
|
|
630
|
+
status = "success";
|
|
631
|
+
this.circuitBreaker.recordSuccess(d.endpoint_id);
|
|
632
|
+
} else {
|
|
633
|
+
status = "failed";
|
|
634
|
+
this.circuitBreaker.recordFailure(d.endpoint_id);
|
|
635
|
+
}
|
|
636
|
+
} catch (err) {
|
|
637
|
+
this.circuitBreaker.recordFailure(d.endpoint_id);
|
|
638
|
+
if (err.name === "AbortError") {
|
|
639
|
+
errorMsg = `Timeout after ${timeout}ms`;
|
|
640
|
+
} else {
|
|
641
|
+
errorMsg = err.message || "Network error";
|
|
642
|
+
}
|
|
643
|
+
} finally {
|
|
644
|
+
clearTimeout(id);
|
|
645
|
+
}
|
|
646
|
+
const attempts = d.attempts + 1;
|
|
647
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
648
|
+
let nextRetryAt = null;
|
|
649
|
+
if (status === "failed") {
|
|
650
|
+
const maxRetries = d.endpoint_max_retries ?? 8;
|
|
651
|
+
const shouldRetryRequest = shouldRetry(statusCode ?? -1);
|
|
652
|
+
if (attempts < maxRetries && shouldRetryRequest) {
|
|
653
|
+
status = "retrying";
|
|
654
|
+
const delay = getNextRetryDelay(attempts);
|
|
655
|
+
nextRetryAt = new Date(Date.now() + delay).toISOString();
|
|
656
|
+
} else {
|
|
657
|
+
status = "dead";
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
this.db.prepare(`
|
|
661
|
+
UPDATE deliveries
|
|
662
|
+
SET status = ?,
|
|
663
|
+
attempts = ?,
|
|
664
|
+
next_retry_at = ?,
|
|
665
|
+
last_status_code = ?,
|
|
666
|
+
last_response_body = ?,
|
|
667
|
+
last_error = ?,
|
|
668
|
+
completed_at = ?
|
|
669
|
+
WHERE id = ?
|
|
670
|
+
`).run(
|
|
671
|
+
status,
|
|
672
|
+
attempts,
|
|
673
|
+
nextRetryAt,
|
|
674
|
+
statusCode,
|
|
675
|
+
responseBody.substring(0, 1e4),
|
|
676
|
+
// Cap payload response length
|
|
677
|
+
errorMsg,
|
|
678
|
+
completedAt,
|
|
679
|
+
d.id
|
|
680
|
+
);
|
|
681
|
+
deliveryEvents.emit("delivery:update", {
|
|
682
|
+
id: d.id,
|
|
683
|
+
event_id: d.event_id,
|
|
684
|
+
endpoint_id: d.endpoint_id,
|
|
685
|
+
endpoint_url: d.endpoint_url,
|
|
686
|
+
status,
|
|
687
|
+
attempts,
|
|
688
|
+
next_retry_at: nextRetryAt,
|
|
689
|
+
last_status_code: statusCode,
|
|
690
|
+
last_error: errorMsg,
|
|
691
|
+
completed_at: completedAt
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// src/api/sse.ts
|
|
699
|
+
function broadcastSSE(event, data) {
|
|
700
|
+
for (const send of activeClients) {
|
|
701
|
+
send(event, data);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
var activeClients, sseRoutes;
|
|
705
|
+
var init_sse = __esm({
|
|
706
|
+
"src/api/sse.ts"() {
|
|
707
|
+
"use strict";
|
|
708
|
+
init_worker();
|
|
709
|
+
activeClients = /* @__PURE__ */ new Set();
|
|
710
|
+
sseRoutes = async (fastify) => {
|
|
711
|
+
fastify.get("/stream", async (request, reply) => {
|
|
712
|
+
reply.raw.writeHead(200, {
|
|
713
|
+
"Content-Type": "text/event-stream",
|
|
714
|
+
"Cache-Control": "no-cache",
|
|
715
|
+
"Connection": "keep-alive",
|
|
716
|
+
"Access-Control-Allow-Origin": "*"
|
|
717
|
+
});
|
|
718
|
+
const send = (event, data) => {
|
|
719
|
+
reply.raw.write(`event: ${event}
|
|
720
|
+
`);
|
|
721
|
+
reply.raw.write(`data: ${JSON.stringify(data)}
|
|
722
|
+
|
|
723
|
+
`);
|
|
724
|
+
};
|
|
725
|
+
activeClients.add(send);
|
|
726
|
+
const heartbeatId = setInterval(() => {
|
|
727
|
+
reply.raw.write(": heartbeat\n\n");
|
|
728
|
+
}, 15e3);
|
|
729
|
+
send("connected", { timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
730
|
+
const onDeliveryUpdate = (data) => {
|
|
731
|
+
send("delivery:update", data);
|
|
732
|
+
};
|
|
733
|
+
deliveryEvents.on("delivery:update", onDeliveryUpdate);
|
|
734
|
+
request.raw.on("close", () => {
|
|
735
|
+
clearInterval(heartbeatId);
|
|
736
|
+
activeClients.delete(send);
|
|
737
|
+
deliveryEvents.off("delivery:update", onDeliveryUpdate);
|
|
738
|
+
});
|
|
739
|
+
reply.hijack();
|
|
740
|
+
});
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// src/ingestion/routes.ts
|
|
746
|
+
var routes_exports = {};
|
|
747
|
+
__export(routes_exports, {
|
|
748
|
+
default: () => ingestionRoutes
|
|
749
|
+
});
|
|
750
|
+
import { nanoid } from "nanoid";
|
|
751
|
+
async function ingestionRoutes(fastify) {
|
|
752
|
+
fastify.post(
|
|
753
|
+
"/webhook/:source",
|
|
754
|
+
async (request, reply) => {
|
|
755
|
+
const { source: sourceName } = request.params;
|
|
756
|
+
const rawBody = request.rawBody;
|
|
757
|
+
if (!rawBody) {
|
|
758
|
+
return reply.status(400).send({
|
|
759
|
+
error: "Missing raw body. Ensure @fastify/raw-body is registered."
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
const db2 = getDatabase();
|
|
763
|
+
const source = db2.prepare("SELECT * FROM sources WHERE name = ? AND active = 1").get(sourceName);
|
|
764
|
+
if (!source) {
|
|
765
|
+
return reply.status(404).send({
|
|
766
|
+
error: `Unknown webhook source "${sourceName}".`
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
const headers = normalizeHeaders(
|
|
770
|
+
request.headers
|
|
771
|
+
);
|
|
772
|
+
const { parsed: parsedBody } = parseBodyEventType(rawBody);
|
|
773
|
+
let signatureValid = null;
|
|
774
|
+
if (source.signing_secret) {
|
|
775
|
+
try {
|
|
776
|
+
const provider = getProvider(source.provider);
|
|
777
|
+
signatureValid = provider.verify(source.signing_secret, headers, rawBody);
|
|
778
|
+
if (!signatureValid) {
|
|
779
|
+
return reply.status(401).send({ error: "Invalid webhook signature." });
|
|
780
|
+
}
|
|
781
|
+
} catch (err) {
|
|
782
|
+
fastify.log.error(err, `Provider verification error for source "${sourceName}"`);
|
|
783
|
+
return reply.status(500).send({ error: "Signature verification failed." });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
let eventType = null;
|
|
787
|
+
try {
|
|
788
|
+
const provider = getProvider(source.provider);
|
|
789
|
+
eventType = provider.extractEventType(headers, parsedBody ?? rawBody.toString("utf8"));
|
|
790
|
+
} catch {
|
|
791
|
+
}
|
|
792
|
+
const eventId = nanoid();
|
|
793
|
+
const contentType = headers["content-type"] ?? null;
|
|
794
|
+
db2.prepare(
|
|
795
|
+
`INSERT INTO events (id, source_id, event_type, headers, body, content_type, signature_valid)
|
|
796
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
797
|
+
).run(
|
|
798
|
+
eventId,
|
|
799
|
+
source.id,
|
|
800
|
+
eventType,
|
|
801
|
+
JSON.stringify(headers),
|
|
802
|
+
rawBody.toString("utf8"),
|
|
803
|
+
contentType,
|
|
804
|
+
signatureValid === null ? null : signatureValid ? 1 : 0
|
|
805
|
+
);
|
|
806
|
+
const endpoints = db2.prepare(
|
|
807
|
+
"SELECT * FROM endpoints WHERE (source_id = ? OR source_id IS NULL) AND active = 1"
|
|
808
|
+
).all(source.id);
|
|
809
|
+
const deliveryIds = [];
|
|
810
|
+
const insertDelivery = db2.prepare(
|
|
811
|
+
`INSERT INTO deliveries (id, event_id, endpoint_id, status)
|
|
812
|
+
VALUES (?, ?, ?, 'pending')`
|
|
813
|
+
);
|
|
814
|
+
const createDeliveries = db2.transaction(() => {
|
|
815
|
+
for (const endpoint of endpoints) {
|
|
816
|
+
let filterPatterns = null;
|
|
817
|
+
if (endpoint.event_filter) {
|
|
818
|
+
try {
|
|
819
|
+
filterPatterns = JSON.parse(endpoint.event_filter);
|
|
820
|
+
} catch {
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (!matchesEventFilter(eventType, filterPatterns)) {
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
const deliveryId = nanoid();
|
|
827
|
+
insertDelivery.run(deliveryId, eventId, endpoint.id);
|
|
828
|
+
deliveryIds.push(deliveryId);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
createDeliveries();
|
|
832
|
+
broadcastSSE("event:new", {
|
|
833
|
+
id: eventId,
|
|
834
|
+
source_id: source.id,
|
|
835
|
+
source_name: source.name,
|
|
836
|
+
source_provider: source.provider,
|
|
837
|
+
event_type: eventType,
|
|
838
|
+
delivery_count: deliveryIds.length,
|
|
839
|
+
received_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
840
|
+
});
|
|
841
|
+
return reply.status(202).send({
|
|
842
|
+
event_id: eventId,
|
|
843
|
+
event_type: eventType,
|
|
844
|
+
deliveries: deliveryIds.length
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
var init_routes = __esm({
|
|
850
|
+
"src/ingestion/routes.ts"() {
|
|
851
|
+
"use strict";
|
|
852
|
+
init_connection();
|
|
853
|
+
init_registry();
|
|
854
|
+
init_parser();
|
|
855
|
+
init_sse();
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// src/api/events.ts
|
|
860
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
861
|
+
var eventRoutes;
|
|
862
|
+
var init_events = __esm({
|
|
863
|
+
"src/api/events.ts"() {
|
|
864
|
+
"use strict";
|
|
865
|
+
eventRoutes = async (fastify) => {
|
|
866
|
+
const { db: db2 } = fastify;
|
|
867
|
+
fastify.get("/", async (request, reply) => {
|
|
868
|
+
const query = request.query;
|
|
869
|
+
const page = Math.max(1, parseInt(query.page) || 1);
|
|
870
|
+
const perPage = Math.min(100, Math.max(1, parseInt(query.per_page) || 20));
|
|
871
|
+
const offset = (page - 1) * perPage;
|
|
872
|
+
const conditions = [];
|
|
873
|
+
const params = [];
|
|
874
|
+
if (query.source) {
|
|
875
|
+
conditions.push("s.name = ?");
|
|
876
|
+
params.push(query.source);
|
|
877
|
+
}
|
|
878
|
+
if (query.event_type) {
|
|
879
|
+
conditions.push("e.event_type = ?");
|
|
880
|
+
params.push(query.event_type);
|
|
881
|
+
}
|
|
882
|
+
if (query.status) {
|
|
883
|
+
conditions.push(`EXISTS (
|
|
884
|
+
SELECT 1 FROM deliveries d
|
|
885
|
+
WHERE d.event_id = e.id AND d.status = ?
|
|
886
|
+
)`);
|
|
887
|
+
params.push(query.status);
|
|
888
|
+
}
|
|
889
|
+
if (query.from) {
|
|
890
|
+
conditions.push("e.received_at >= ?");
|
|
891
|
+
params.push(query.from);
|
|
892
|
+
}
|
|
893
|
+
if (query.to) {
|
|
894
|
+
conditions.push("e.received_at <= ?");
|
|
895
|
+
params.push(query.to);
|
|
896
|
+
}
|
|
897
|
+
if (query.search) {
|
|
898
|
+
conditions.push("(e.body LIKE ? OR e.event_type LIKE ? OR e.id LIKE ?)");
|
|
899
|
+
const wild = `%${query.search}%`;
|
|
900
|
+
params.push(wild, wild, wild);
|
|
901
|
+
}
|
|
902
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
903
|
+
const totalRow = db2.prepare(`
|
|
904
|
+
SELECT COUNT(DISTINCT e.id) as count
|
|
905
|
+
FROM events e
|
|
906
|
+
JOIN sources s ON e.source_id = s.id
|
|
907
|
+
${whereClause}
|
|
908
|
+
`).get(...params);
|
|
909
|
+
const total = totalRow?.count || 0;
|
|
910
|
+
const eventsQuery = `
|
|
911
|
+
SELECT e.id, e.event_type, e.received_at, e.signature_valid,
|
|
912
|
+
s.name as source_name, s.provider as source_provider,
|
|
913
|
+
COUNT(d.id) as delivery_count,
|
|
914
|
+
SUM(CASE WHEN d.status = 'success' THEN 1 ELSE 0 END) as success_count,
|
|
915
|
+
SUM(CASE WHEN d.status IN ('failed', 'retrying', 'dead') THEN 1 ELSE 0 END) as failed_count
|
|
916
|
+
FROM events e
|
|
917
|
+
JOIN sources s ON e.source_id = s.id
|
|
918
|
+
LEFT JOIN deliveries d ON d.event_id = e.id
|
|
919
|
+
${whereClause}
|
|
920
|
+
GROUP BY e.id
|
|
921
|
+
ORDER BY e.received_at DESC
|
|
922
|
+
LIMIT ? OFFSET ?
|
|
923
|
+
`;
|
|
924
|
+
const events = db2.prepare(eventsQuery).all(...params, perPage, offset);
|
|
925
|
+
const response = {
|
|
926
|
+
data: events,
|
|
927
|
+
total,
|
|
928
|
+
page,
|
|
929
|
+
per_page: perPage,
|
|
930
|
+
total_pages: Math.ceil(total / perPage)
|
|
931
|
+
};
|
|
932
|
+
return response;
|
|
933
|
+
});
|
|
934
|
+
fastify.get("/:id", async (request, reply) => {
|
|
935
|
+
const { id } = request.params;
|
|
936
|
+
const event = db2.prepare(`
|
|
937
|
+
SELECT e.*, s.name as source_name, s.provider as source_provider
|
|
938
|
+
FROM events e
|
|
939
|
+
JOIN sources s ON e.source_id = s.id
|
|
940
|
+
WHERE e.id = ?
|
|
941
|
+
`).get(id);
|
|
942
|
+
if (!event) {
|
|
943
|
+
return reply.status(404).send({ error: "Event not found" });
|
|
944
|
+
}
|
|
945
|
+
const deliveries = db2.prepare(`
|
|
946
|
+
SELECT d.*, e.url as endpoint_url
|
|
947
|
+
FROM deliveries d
|
|
948
|
+
JOIN endpoints e ON d.endpoint_id = e.id
|
|
949
|
+
WHERE d.event_id = ?
|
|
950
|
+
ORDER BY d.created_at ASC
|
|
951
|
+
`).all(id);
|
|
952
|
+
return {
|
|
953
|
+
...event,
|
|
954
|
+
deliveries
|
|
955
|
+
};
|
|
956
|
+
});
|
|
957
|
+
fastify.post("/:id/replay", async (request, reply) => {
|
|
958
|
+
const { id } = request.params;
|
|
959
|
+
const event = db2.prepare("SELECT id FROM events WHERE id = ?").get(id);
|
|
960
|
+
if (!event) {
|
|
961
|
+
return reply.status(404).send({ error: "Event not found" });
|
|
962
|
+
}
|
|
963
|
+
const endpoints = db2.prepare(`
|
|
964
|
+
SELECT ep.id
|
|
965
|
+
FROM endpoints ep
|
|
966
|
+
JOIN events ev ON ev.source_id = ep.source_id
|
|
967
|
+
WHERE ev.id = ? AND ep.active = 1
|
|
968
|
+
`).all(id);
|
|
969
|
+
const newDeliveryIds = [];
|
|
970
|
+
const insertDelivery = db2.prepare(`
|
|
971
|
+
INSERT INTO deliveries (id, event_id, endpoint_id, status, attempts)
|
|
972
|
+
VALUES (?, ?, ?, 'pending', 0)
|
|
973
|
+
`);
|
|
974
|
+
db2.transaction(() => {
|
|
975
|
+
for (const ep of endpoints) {
|
|
976
|
+
const deliveryId = `del_${nanoid2(12)}`;
|
|
977
|
+
insertDelivery.run(deliveryId, id, ep.id);
|
|
978
|
+
newDeliveryIds.push(deliveryId);
|
|
979
|
+
}
|
|
980
|
+
})();
|
|
981
|
+
return {
|
|
982
|
+
success: true,
|
|
983
|
+
message: `Requeued ${newDeliveryIds.length} deliveries`,
|
|
984
|
+
delivery_ids: newDeliveryIds
|
|
985
|
+
};
|
|
986
|
+
});
|
|
987
|
+
fastify.delete("/:id", async (request, reply) => {
|
|
988
|
+
const { id } = request.params;
|
|
989
|
+
const result = db2.prepare("DELETE FROM events WHERE id = ?").run(id);
|
|
990
|
+
if (result.changes === 0) {
|
|
991
|
+
return reply.status(404).send({ error: "Event not found" });
|
|
992
|
+
}
|
|
993
|
+
return { success: true };
|
|
994
|
+
});
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// src/api/endpoints.ts
|
|
1000
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
1001
|
+
var endpointRoutes;
|
|
1002
|
+
var init_endpoints = __esm({
|
|
1003
|
+
"src/api/endpoints.ts"() {
|
|
1004
|
+
"use strict";
|
|
1005
|
+
endpointRoutes = async (fastify) => {
|
|
1006
|
+
const { db: db2 } = fastify;
|
|
1007
|
+
fastify.get("/", async (request, reply) => {
|
|
1008
|
+
const endpoints = db2.prepare(`
|
|
1009
|
+
SELECT e.*, s.name as source_name, s.provider as source_provider
|
|
1010
|
+
FROM endpoints e
|
|
1011
|
+
LEFT JOIN sources s ON e.source_id = s.id
|
|
1012
|
+
ORDER BY e.created_at DESC
|
|
1013
|
+
`).all();
|
|
1014
|
+
return endpoints.map((ep) => ({
|
|
1015
|
+
...ep,
|
|
1016
|
+
event_filter: ep.event_filter ? JSON.parse(ep.event_filter) : null,
|
|
1017
|
+
headers: ep.headers ? JSON.parse(ep.headers) : null
|
|
1018
|
+
}));
|
|
1019
|
+
});
|
|
1020
|
+
fastify.post("/", async (request, reply) => {
|
|
1021
|
+
const body = request.body;
|
|
1022
|
+
if (!body.url) {
|
|
1023
|
+
return reply.status(400).send({ error: "url is required" });
|
|
1024
|
+
}
|
|
1025
|
+
const id = `ep_${nanoid3(12)}`;
|
|
1026
|
+
const eventFilter = body.event_filter ? JSON.stringify(body.event_filter) : null;
|
|
1027
|
+
const customHeaders = body.headers ? JSON.stringify(body.headers) : null;
|
|
1028
|
+
db2.prepare(`
|
|
1029
|
+
INSERT INTO endpoints (
|
|
1030
|
+
id, url, source_id, event_filter, headers, timeout_ms, max_retries, active
|
|
1031
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1032
|
+
`).run(
|
|
1033
|
+
id,
|
|
1034
|
+
body.url,
|
|
1035
|
+
body.source_id || null,
|
|
1036
|
+
eventFilter,
|
|
1037
|
+
customHeaders,
|
|
1038
|
+
body.timeout_ms ?? 3e4,
|
|
1039
|
+
body.max_retries ?? 8,
|
|
1040
|
+
body.active !== void 0 ? body.active ? 1 : 0 : 1
|
|
1041
|
+
);
|
|
1042
|
+
const created = db2.prepare("SELECT * FROM endpoints WHERE id = ?").get(id);
|
|
1043
|
+
return {
|
|
1044
|
+
...created,
|
|
1045
|
+
event_filter: created.event_filter ? JSON.parse(created.event_filter) : null,
|
|
1046
|
+
headers: created.headers ? JSON.parse(created.headers) : null
|
|
1047
|
+
};
|
|
1048
|
+
});
|
|
1049
|
+
fastify.put("/:id", async (request, reply) => {
|
|
1050
|
+
const { id } = request.params;
|
|
1051
|
+
const body = request.body;
|
|
1052
|
+
const existing = db2.prepare("SELECT id FROM endpoints WHERE id = ?").get(id);
|
|
1053
|
+
if (!existing) {
|
|
1054
|
+
return reply.status(404).send({ error: "Endpoint not found" });
|
|
1055
|
+
}
|
|
1056
|
+
const eventFilter = body.event_filter ? JSON.stringify(body.event_filter) : null;
|
|
1057
|
+
const customHeaders = body.headers ? JSON.stringify(body.headers) : null;
|
|
1058
|
+
db2.prepare(`
|
|
1059
|
+
UPDATE endpoints
|
|
1060
|
+
SET url = COALESCE(?, url),
|
|
1061
|
+
source_id = COALESCE(?, source_id),
|
|
1062
|
+
event_filter = ?,
|
|
1063
|
+
headers = ?,
|
|
1064
|
+
timeout_ms = COALESCE(?, timeout_ms),
|
|
1065
|
+
max_retries = COALESCE(?, max_retries),
|
|
1066
|
+
active = COALESCE(?, active)
|
|
1067
|
+
WHERE id = ?
|
|
1068
|
+
`).run(
|
|
1069
|
+
body.url,
|
|
1070
|
+
body.source_id,
|
|
1071
|
+
eventFilter,
|
|
1072
|
+
customHeaders,
|
|
1073
|
+
body.timeout_ms,
|
|
1074
|
+
body.max_retries,
|
|
1075
|
+
body.active !== void 0 ? body.active ? 1 : 0 : null,
|
|
1076
|
+
id
|
|
1077
|
+
);
|
|
1078
|
+
const updated = db2.prepare("SELECT * FROM endpoints WHERE id = ?").get(id);
|
|
1079
|
+
return {
|
|
1080
|
+
...updated,
|
|
1081
|
+
event_filter: updated.event_filter ? JSON.parse(updated.event_filter) : null,
|
|
1082
|
+
headers: updated.headers ? JSON.parse(updated.headers) : null
|
|
1083
|
+
};
|
|
1084
|
+
});
|
|
1085
|
+
fastify.delete("/:id", async (request, reply) => {
|
|
1086
|
+
const { id } = request.params;
|
|
1087
|
+
const result = db2.prepare("DELETE FROM endpoints WHERE id = ?").run(id);
|
|
1088
|
+
if (result.changes === 0) {
|
|
1089
|
+
return reply.status(404).send({ error: "Endpoint not found" });
|
|
1090
|
+
}
|
|
1091
|
+
return { success: true };
|
|
1092
|
+
});
|
|
1093
|
+
fastify.post("/:id/test", async (request, reply) => {
|
|
1094
|
+
const { id } = request.params;
|
|
1095
|
+
const ep = db2.prepare("SELECT url, headers, timeout_ms FROM endpoints WHERE id = ?").get(id);
|
|
1096
|
+
if (!ep) {
|
|
1097
|
+
return reply.status(404).send({ error: "Endpoint not found" });
|
|
1098
|
+
}
|
|
1099
|
+
const customHeaders = ep.headers ? JSON.parse(ep.headers) : {};
|
|
1100
|
+
const timeout = ep.timeout_ms || 3e4;
|
|
1101
|
+
const testPayload = {
|
|
1102
|
+
event: "hookdash.test",
|
|
1103
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1104
|
+
test: true,
|
|
1105
|
+
message: "This is a test webhook from hookdash dashboard"
|
|
1106
|
+
};
|
|
1107
|
+
const controller = new AbortController();
|
|
1108
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
1109
|
+
try {
|
|
1110
|
+
const response = await fetch(ep.url, {
|
|
1111
|
+
method: "POST",
|
|
1112
|
+
headers: {
|
|
1113
|
+
"content-type": "application/json",
|
|
1114
|
+
"x-hookdash-test": "true",
|
|
1115
|
+
...customHeaders
|
|
1116
|
+
},
|
|
1117
|
+
body: JSON.stringify(testPayload),
|
|
1118
|
+
signal: controller.signal
|
|
1119
|
+
});
|
|
1120
|
+
const responseText = await response.text();
|
|
1121
|
+
return {
|
|
1122
|
+
success: response.ok,
|
|
1123
|
+
status_code: response.status,
|
|
1124
|
+
response: responseText.substring(0, 1e3)
|
|
1125
|
+
};
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
return {
|
|
1128
|
+
success: false,
|
|
1129
|
+
error: err.name === "AbortError" ? `Timeout after ${timeout}ms` : err.message
|
|
1130
|
+
};
|
|
1131
|
+
} finally {
|
|
1132
|
+
clearTimeout(timer);
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
// src/api/stats.ts
|
|
1140
|
+
var statsRoutes;
|
|
1141
|
+
var init_stats = __esm({
|
|
1142
|
+
"src/api/stats.ts"() {
|
|
1143
|
+
"use strict";
|
|
1144
|
+
statsRoutes = async (fastify) => {
|
|
1145
|
+
const { db: db2 } = fastify;
|
|
1146
|
+
fastify.get("/", async (request, reply) => {
|
|
1147
|
+
const todayStr = (/* @__PURE__ */ new Date()).toISOString().split("T")[0] + "T00:00:00.000Z";
|
|
1148
|
+
const eventCounts = db2.prepare(`
|
|
1149
|
+
SELECT
|
|
1150
|
+
COUNT(id) as total,
|
|
1151
|
+
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) as today
|
|
1152
|
+
FROM events
|
|
1153
|
+
`).get(todayStr);
|
|
1154
|
+
const totalEvents = eventCounts?.total || 0;
|
|
1155
|
+
const eventsToday = eventCounts?.today || 0;
|
|
1156
|
+
const deliveryStats = db2.prepare(`
|
|
1157
|
+
SELECT
|
|
1158
|
+
status,
|
|
1159
|
+
COUNT(id) as count
|
|
1160
|
+
FROM deliveries
|
|
1161
|
+
GROUP BY status
|
|
1162
|
+
`).all();
|
|
1163
|
+
let totalDeliveries = 0;
|
|
1164
|
+
let successfulDeliveries = 0;
|
|
1165
|
+
let failedDeliveries = 0;
|
|
1166
|
+
let deadDeliveries = 0;
|
|
1167
|
+
let pendingDeliveries = 0;
|
|
1168
|
+
for (const row of deliveryStats) {
|
|
1169
|
+
totalDeliveries += row.count;
|
|
1170
|
+
if (row.status === "success") {
|
|
1171
|
+
successfulDeliveries = row.count;
|
|
1172
|
+
} else if (row.status === "failed" || row.status === "retrying") {
|
|
1173
|
+
failedDeliveries += row.count;
|
|
1174
|
+
if (row.status === "pending") pendingDeliveries = row.count;
|
|
1175
|
+
} else if (row.status === "dead") {
|
|
1176
|
+
deadDeliveries = row.count;
|
|
1177
|
+
} else if (row.status === "pending") {
|
|
1178
|
+
pendingDeliveries = row.count;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
const successRate = totalDeliveries > 0 ? Math.round(successfulDeliveries / (successfulDeliveries + deadDeliveries + failedDeliveries || 1) * 1e3) / 10 : 100;
|
|
1182
|
+
const activeHours = [];
|
|
1183
|
+
for (let i = 23; i >= 0; i--) {
|
|
1184
|
+
const d = new Date(Date.now() - i * 3600 * 1e3);
|
|
1185
|
+
d.setMinutes(0, 0, 0);
|
|
1186
|
+
const isoStr = d.toISOString();
|
|
1187
|
+
const formatted = isoStr.replace("T", " ").substring(0, 16);
|
|
1188
|
+
activeHours.push({ hour: formatted, count: 0 });
|
|
1189
|
+
}
|
|
1190
|
+
const last24h = new Date(Date.now() - 24 * 3600 * 1e3).toISOString();
|
|
1191
|
+
const dbHours = db2.prepare(`
|
|
1192
|
+
SELECT strftime('%Y-%m-%d %H:00', received_at) as hour_bucket, COUNT(id) as count
|
|
1193
|
+
FROM events
|
|
1194
|
+
WHERE received_at >= ?
|
|
1195
|
+
GROUP BY hour_bucket
|
|
1196
|
+
ORDER BY hour_bucket ASC
|
|
1197
|
+
`).all(last24h);
|
|
1198
|
+
for (const dbHour of dbHours) {
|
|
1199
|
+
const match = activeHours.find((h) => h.hour === dbHour.hour_bucket);
|
|
1200
|
+
if (match) {
|
|
1201
|
+
match.count = dbHour.count;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const topSources = db2.prepare(`
|
|
1205
|
+
SELECT s.name, COUNT(e.id) as count
|
|
1206
|
+
FROM events e
|
|
1207
|
+
JOIN sources s ON e.source_id = s.id
|
|
1208
|
+
GROUP BY s.name
|
|
1209
|
+
ORDER BY count DESC
|
|
1210
|
+
LIMIT 5
|
|
1211
|
+
`).all();
|
|
1212
|
+
const topEventTypes = db2.prepare(`
|
|
1213
|
+
SELECT event_type as type, COUNT(id) as count
|
|
1214
|
+
FROM events
|
|
1215
|
+
WHERE event_type IS NOT NULL
|
|
1216
|
+
GROUP BY event_type
|
|
1217
|
+
ORDER BY count DESC
|
|
1218
|
+
LIMIT 10
|
|
1219
|
+
`).all();
|
|
1220
|
+
const stats = {
|
|
1221
|
+
total_events: totalEvents,
|
|
1222
|
+
events_today: eventsToday,
|
|
1223
|
+
total_deliveries: totalDeliveries,
|
|
1224
|
+
successful_deliveries: successfulDeliveries,
|
|
1225
|
+
failed_deliveries: failedDeliveries,
|
|
1226
|
+
dead_deliveries: deadDeliveries,
|
|
1227
|
+
pending_deliveries: pendingDeliveries,
|
|
1228
|
+
success_rate: successRate,
|
|
1229
|
+
events_per_hour: activeHours,
|
|
1230
|
+
top_sources: topSources,
|
|
1231
|
+
top_event_types: topEventTypes
|
|
1232
|
+
};
|
|
1233
|
+
return stats;
|
|
1234
|
+
});
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
// src/api/routes.ts
|
|
1240
|
+
var routes_exports2 = {};
|
|
1241
|
+
__export(routes_exports2, {
|
|
1242
|
+
apiRoutes: () => apiRoutes
|
|
1243
|
+
});
|
|
1244
|
+
var apiRoutes;
|
|
1245
|
+
var init_routes2 = __esm({
|
|
1246
|
+
"src/api/routes.ts"() {
|
|
1247
|
+
"use strict";
|
|
1248
|
+
init_events();
|
|
1249
|
+
init_endpoints();
|
|
1250
|
+
init_stats();
|
|
1251
|
+
init_sse();
|
|
1252
|
+
apiRoutes = async (fastify) => {
|
|
1253
|
+
await fastify.register(eventRoutes, { prefix: "/events" });
|
|
1254
|
+
await fastify.register(endpointRoutes, { prefix: "/endpoints" });
|
|
1255
|
+
await fastify.register(statsRoutes, { prefix: "/stats" });
|
|
1256
|
+
await fastify.register(sseRoutes);
|
|
1257
|
+
fastify.get("/health", async () => {
|
|
1258
|
+
return {
|
|
1259
|
+
status: "ok",
|
|
1260
|
+
version: "0.1.0",
|
|
1261
|
+
uptime: process.uptime(),
|
|
1262
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1263
|
+
};
|
|
1264
|
+
});
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
// src/index.ts
|
|
1270
|
+
import { Command } from "commander";
|
|
1271
|
+
|
|
1272
|
+
// src/config/loader.ts
|
|
1273
|
+
import { readFileSync, existsSync } from "fs";
|
|
1274
|
+
import { resolve } from "path";
|
|
1275
|
+
import { parse as parseYaml } from "yaml";
|
|
1276
|
+
|
|
1277
|
+
// src/config/schema.ts
|
|
1278
|
+
import { z } from "zod";
|
|
1279
|
+
var endpointConfigSchema = z.object({
|
|
1280
|
+
url: z.string().url(),
|
|
1281
|
+
events: z.array(z.string()).optional(),
|
|
1282
|
+
headers: z.record(z.string()).optional(),
|
|
1283
|
+
timeout_ms: z.number().int().positive().optional(),
|
|
1284
|
+
max_retries: z.number().int().min(0).max(50).optional()
|
|
1285
|
+
});
|
|
1286
|
+
var sourceConfigSchema = z.object({
|
|
1287
|
+
name: z.string().min(1).regex(/^[a-z0-9-]+$/, "Source name must be lowercase alphanumeric with hyphens"),
|
|
1288
|
+
provider: z.enum(["stripe", "github", "twilio", "shopify", "generic"]).default("generic"),
|
|
1289
|
+
signing_secret: z.string().optional(),
|
|
1290
|
+
endpoints: z.array(endpointConfigSchema).min(1)
|
|
1291
|
+
});
|
|
1292
|
+
var configSchema = z.object({
|
|
1293
|
+
server: z.object({
|
|
1294
|
+
port: z.number().int().min(1).max(65535).default(9090),
|
|
1295
|
+
host: z.string().default("0.0.0.0")
|
|
1296
|
+
}).default({}),
|
|
1297
|
+
database: z.object({
|
|
1298
|
+
path: z.string().default("./hookdash.db")
|
|
1299
|
+
}).default({}),
|
|
1300
|
+
delivery: z.object({
|
|
1301
|
+
poll_interval: z.number().int().min(100).max(6e4).default(1e3),
|
|
1302
|
+
default_timeout: z.number().int().min(1e3).max(12e4).default(3e4),
|
|
1303
|
+
default_max_retries: z.number().int().min(0).max(50).default(8)
|
|
1304
|
+
}).default({}),
|
|
1305
|
+
sources: z.array(sourceConfigSchema).default([])
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
// src/config/loader.ts
|
|
1309
|
+
function interpolateEnvVars(value) {
|
|
1310
|
+
if (typeof value === "string") {
|
|
1311
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, envKey) => {
|
|
1312
|
+
const envValue = process.env[envKey.trim()];
|
|
1313
|
+
if (envValue === void 0) {
|
|
1314
|
+
console.warn(`[hookdash] Warning: Environment variable "${envKey}" is not set`);
|
|
1315
|
+
return "";
|
|
1316
|
+
}
|
|
1317
|
+
return envValue;
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
if (Array.isArray(value)) {
|
|
1321
|
+
return value.map(interpolateEnvVars);
|
|
1322
|
+
}
|
|
1323
|
+
if (value !== null && typeof value === "object") {
|
|
1324
|
+
const result = {};
|
|
1325
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1326
|
+
result[k] = interpolateEnvVars(v);
|
|
1327
|
+
}
|
|
1328
|
+
return result;
|
|
1329
|
+
}
|
|
1330
|
+
return value;
|
|
1331
|
+
}
|
|
1332
|
+
function findConfigFile(customPath) {
|
|
1333
|
+
if (customPath) {
|
|
1334
|
+
const resolved = resolve(customPath);
|
|
1335
|
+
if (existsSync(resolved)) return resolved;
|
|
1336
|
+
throw new Error(`Config file not found: ${resolved}`);
|
|
1337
|
+
}
|
|
1338
|
+
const candidates = [
|
|
1339
|
+
resolve("hookdash.config.yml"),
|
|
1340
|
+
resolve("hookdash.config.yaml"),
|
|
1341
|
+
resolve(".hookdash.yml")
|
|
1342
|
+
];
|
|
1343
|
+
for (const candidate of candidates) {
|
|
1344
|
+
if (existsSync(candidate)) return candidate;
|
|
1345
|
+
}
|
|
1346
|
+
return null;
|
|
1347
|
+
}
|
|
1348
|
+
function loadConfig(options = {}) {
|
|
1349
|
+
let rawConfig = {};
|
|
1350
|
+
const configFile = findConfigFile(options.configPath);
|
|
1351
|
+
if (configFile) {
|
|
1352
|
+
const content = readFileSync(configFile, "utf-8");
|
|
1353
|
+
const parsed = parseYaml(content);
|
|
1354
|
+
if (parsed && typeof parsed === "object") {
|
|
1355
|
+
rawConfig = interpolateEnvVars(parsed);
|
|
1356
|
+
}
|
|
1357
|
+
console.log(`[hookdash] Loaded config from ${configFile}`);
|
|
1358
|
+
}
|
|
1359
|
+
if (options.port) {
|
|
1360
|
+
rawConfig.server = { ...rawConfig.server || {}, port: options.port };
|
|
1361
|
+
}
|
|
1362
|
+
if (options.host) {
|
|
1363
|
+
rawConfig.server = { ...rawConfig.server || {}, host: options.host };
|
|
1364
|
+
}
|
|
1365
|
+
if (options.dbPath) {
|
|
1366
|
+
rawConfig.database = { ...rawConfig.database || {}, path: options.dbPath };
|
|
1367
|
+
}
|
|
1368
|
+
const result = configSchema.safeParse(rawConfig);
|
|
1369
|
+
if (!result.success) {
|
|
1370
|
+
const errors = result.error.errors.map((e) => ` - ${e.path.join(".")}: ${e.message}`).join("\n");
|
|
1371
|
+
throw new Error(`Invalid configuration:
|
|
1372
|
+
${errors}`);
|
|
1373
|
+
}
|
|
1374
|
+
return result.data;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// src/server.ts
|
|
1378
|
+
init_connection();
|
|
1379
|
+
import Fastify from "fastify";
|
|
1380
|
+
import fastifyCors from "@fastify/cors";
|
|
1381
|
+
import fastifyStatic from "@fastify/static";
|
|
1382
|
+
import fastifyRawBody from "fastify-raw-body";
|
|
1383
|
+
import { resolve as resolve3, dirname as dirname2 } from "path";
|
|
1384
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1385
|
+
import { fileURLToPath } from "url";
|
|
1386
|
+
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
1387
|
+
async function createServer(config) {
|
|
1388
|
+
const app = Fastify({
|
|
1389
|
+
logger: {
|
|
1390
|
+
transport: {
|
|
1391
|
+
target: "pino-pretty",
|
|
1392
|
+
options: {
|
|
1393
|
+
translateTime: "HH:MM:ss",
|
|
1394
|
+
ignore: "pid,hostname"
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
},
|
|
1398
|
+
bodyLimit: 10 * 1024 * 1024
|
|
1399
|
+
// 10MB max webhook payload
|
|
1400
|
+
});
|
|
1401
|
+
await app.register(fastifyCors, {
|
|
1402
|
+
origin: true,
|
|
1403
|
+
methods: ["GET", "POST", "PUT", "DELETE"]
|
|
1404
|
+
});
|
|
1405
|
+
await app.register(fastifyRawBody, {
|
|
1406
|
+
field: "rawBody",
|
|
1407
|
+
global: false,
|
|
1408
|
+
encoding: false,
|
|
1409
|
+
// Return Buffer
|
|
1410
|
+
runFirst: true
|
|
1411
|
+
});
|
|
1412
|
+
const db2 = getDatabase(config);
|
|
1413
|
+
seedFromConfig(config);
|
|
1414
|
+
app.decorate("hookdashConfig", config);
|
|
1415
|
+
app.decorate("db", db2);
|
|
1416
|
+
const ingestionRoutes2 = (await Promise.resolve().then(() => (init_routes(), routes_exports))).default;
|
|
1417
|
+
await app.register(ingestionRoutes2);
|
|
1418
|
+
const { apiRoutes: apiRoutes2 } = await Promise.resolve().then(() => (init_routes2(), routes_exports2));
|
|
1419
|
+
await app.register(apiRoutes2, { prefix: "/api" });
|
|
1420
|
+
const dashboardPaths = [
|
|
1421
|
+
resolve3(__dirname, "../dashboard/dist"),
|
|
1422
|
+
// Dev: source tree
|
|
1423
|
+
resolve3(__dirname, "../../dashboard/dist"),
|
|
1424
|
+
// Built: dist/
|
|
1425
|
+
resolve3(dirname2(__dirname), "dashboard/dist")
|
|
1426
|
+
// npm package
|
|
1427
|
+
];
|
|
1428
|
+
let dashboardPath = null;
|
|
1429
|
+
for (const p of dashboardPaths) {
|
|
1430
|
+
if (existsSync2(p)) {
|
|
1431
|
+
dashboardPath = p;
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (dashboardPath) {
|
|
1436
|
+
await app.register(fastifyStatic, {
|
|
1437
|
+
root: dashboardPath,
|
|
1438
|
+
prefix: "/",
|
|
1439
|
+
wildcard: false
|
|
1440
|
+
});
|
|
1441
|
+
app.setNotFoundHandler((request, reply) => {
|
|
1442
|
+
if (request.url.startsWith("/api/") || request.url.startsWith("/webhook/")) {
|
|
1443
|
+
return reply.status(404).send({ error: "Not found" });
|
|
1444
|
+
}
|
|
1445
|
+
return reply.sendFile("index.html");
|
|
1446
|
+
});
|
|
1447
|
+
console.log(`[hookdash] Dashboard serving from ${dashboardPath}`);
|
|
1448
|
+
} else {
|
|
1449
|
+
console.log("[hookdash] Dashboard not found (run `npm run build:dashboard` to enable)");
|
|
1450
|
+
app.get("/", async (_request, reply) => {
|
|
1451
|
+
return reply.type("text/html").send(`
|
|
1452
|
+
<!DOCTYPE html>
|
|
1453
|
+
<html>
|
|
1454
|
+
<head><title>hookdash</title></head>
|
|
1455
|
+
<body style="font-family:system-ui;background:#0a0a0f;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
|
|
1456
|
+
<div style="text-align:center">
|
|
1457
|
+
<h1>\u223F hookdash</h1>
|
|
1458
|
+
<p style="color:#888">Dashboard not built. Run <code style="background:#1e1e2e;padding:2px 8px;border-radius:4px">npm run build:dashboard</code></p>
|
|
1459
|
+
<p style="color:#888;margin-top:20px">API available at <a href="/api/health" style="color:#3b82f6">/api/health</a></p>
|
|
1460
|
+
</div>
|
|
1461
|
+
</body>
|
|
1462
|
+
</html>
|
|
1463
|
+
`);
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
return app;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// src/index.ts
|
|
1470
|
+
init_worker();
|
|
1471
|
+
init_connection();
|
|
1472
|
+
var program = new Command();
|
|
1473
|
+
program.name("hookdash").description("Zero-config, self-hosted webhook gateway with a beautiful dashboard").version("0.1.0");
|
|
1474
|
+
program.command("start").description("Start the hookdash server").option("-p, --port <port>", "Server port", parseInt).option("-h, --host <host>", "Server host").option("-c, --config <path>", "Path to config file").option("-d, --db <path>", "Database file path").action(async (options) => {
|
|
1475
|
+
try {
|
|
1476
|
+
console.log("");
|
|
1477
|
+
console.log(" \u223F hookdash v0.1.0");
|
|
1478
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1479
|
+
console.log("");
|
|
1480
|
+
const config = loadConfig({
|
|
1481
|
+
configPath: options.config,
|
|
1482
|
+
port: options.port,
|
|
1483
|
+
host: options.host,
|
|
1484
|
+
dbPath: options.db
|
|
1485
|
+
});
|
|
1486
|
+
const app = await createServer(config);
|
|
1487
|
+
const worker = new DeliveryWorker(config);
|
|
1488
|
+
worker.start();
|
|
1489
|
+
const shutdown = async (signal) => {
|
|
1490
|
+
console.log(`
|
|
1491
|
+
[hookdash] ${signal} received, shutting down...`);
|
|
1492
|
+
worker.stop();
|
|
1493
|
+
await app.close();
|
|
1494
|
+
closeDatabase();
|
|
1495
|
+
console.log("[hookdash] Goodbye! \u{1F44B}");
|
|
1496
|
+
process.exit(0);
|
|
1497
|
+
};
|
|
1498
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
1499
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1500
|
+
await app.listen({
|
|
1501
|
+
port: config.server.port,
|
|
1502
|
+
host: config.server.host
|
|
1503
|
+
});
|
|
1504
|
+
console.log("");
|
|
1505
|
+
console.log(` \u{1FA9D} Webhook endpoint: http://${config.server.host === "0.0.0.0" ? "localhost" : config.server.host}:${config.server.port}/webhook/:source`);
|
|
1506
|
+
console.log(` \u{1F4CA} Dashboard: http://${config.server.host === "0.0.0.0" ? "localhost" : config.server.host}:${config.server.port}`);
|
|
1507
|
+
console.log(` \u{1F4E1} API: http://${config.server.host === "0.0.0.0" ? "localhost" : config.server.host}:${config.server.port}/api/health`);
|
|
1508
|
+
console.log("");
|
|
1509
|
+
if (config.sources.length === 0) {
|
|
1510
|
+
console.log(" \u2139 No sources configured. Create a hookdash.config.yml to get started.");
|
|
1511
|
+
console.log(" See: https://github.com/hookdash/hookdash#configuration");
|
|
1512
|
+
console.log("");
|
|
1513
|
+
}
|
|
1514
|
+
} catch (err) {
|
|
1515
|
+
console.error("[hookdash] Failed to start:", err.message);
|
|
1516
|
+
process.exit(1);
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
program.command("init").description("Create an example hookdash.config.yml in the current directory").action(async () => {
|
|
1520
|
+
const { writeFileSync, existsSync: existsSync3 } = await import("fs");
|
|
1521
|
+
const configPath = "hookdash.config.yml";
|
|
1522
|
+
if (existsSync3(configPath)) {
|
|
1523
|
+
console.log(`[hookdash] ${configPath} already exists. Skipping.`);
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
const exampleConfig = `# hookdash configuration
|
|
1527
|
+
# Docs: https://github.com/hookdash/hookdash#configuration
|
|
1528
|
+
|
|
1529
|
+
server:
|
|
1530
|
+
port: 9090
|
|
1531
|
+
host: 0.0.0.0
|
|
1532
|
+
|
|
1533
|
+
database:
|
|
1534
|
+
path: ./hookdash.db
|
|
1535
|
+
|
|
1536
|
+
delivery:
|
|
1537
|
+
poll_interval: 1000 # ms between delivery polls
|
|
1538
|
+
default_timeout: 30000 # ms per delivery attempt
|
|
1539
|
+
default_max_retries: 8 # max retries before dead letter
|
|
1540
|
+
|
|
1541
|
+
sources:
|
|
1542
|
+
# Stripe webhooks
|
|
1543
|
+
# - name: stripe
|
|
1544
|
+
# provider: stripe
|
|
1545
|
+
# signing_secret: \${STRIPE_WEBHOOK_SECRET}
|
|
1546
|
+
# endpoints:
|
|
1547
|
+
# - url: http://localhost:3000/api/webhooks/stripe
|
|
1548
|
+
# events: ["payment_intent.*", "charge.*"]
|
|
1549
|
+
|
|
1550
|
+
# GitHub webhooks
|
|
1551
|
+
# - name: github
|
|
1552
|
+
# provider: github
|
|
1553
|
+
# signing_secret: \${GITHUB_WEBHOOK_SECRET}
|
|
1554
|
+
# endpoints:
|
|
1555
|
+
# - url: http://localhost:3000/api/webhooks/github
|
|
1556
|
+
|
|
1557
|
+
# Generic webhooks (HMAC-SHA256)
|
|
1558
|
+
- name: my-service
|
|
1559
|
+
provider: generic
|
|
1560
|
+
signing_secret: my-secret-key
|
|
1561
|
+
endpoints:
|
|
1562
|
+
- url: http://localhost:3000/hooks
|
|
1563
|
+
`;
|
|
1564
|
+
writeFileSync(configPath, exampleConfig);
|
|
1565
|
+
console.log(`[hookdash] Created ${configPath}`);
|
|
1566
|
+
console.log("[hookdash] Edit it and run: hookdash start");
|
|
1567
|
+
});
|
|
1568
|
+
program.parse();
|
|
1569
|
+
//# sourceMappingURL=index.js.map
|