hookarmor 1.0.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/src/server.js ADDED
@@ -0,0 +1,365 @@
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const { randomUUID } = require('crypto');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const http = require('http');
7
+ const { WebSocketServer, WebSocket } = require('ws');
8
+
9
+ const Storage = require('./storage');
10
+ const Dispatcher = require('./dispatcher');
11
+ const RetryWorker = require('./worker');
12
+
13
+ function isPrivateOrMetadataUrl(urlString) {
14
+ try {
15
+ const parsed = new URL(urlString);
16
+ const hostname = parsed.hostname.toLowerCase();
17
+ if (
18
+ hostname === '169.254.169.254' ||
19
+ hostname === 'metadata.google.internal' ||
20
+ hostname === '100.100.100.200' ||
21
+ hostname.startsWith('169.254.')
22
+ ) {
23
+ return true;
24
+ }
25
+ return false;
26
+ } catch (e) {
27
+ return true;
28
+ }
29
+ }
30
+
31
+ function createServer(options = {}) {
32
+ const app = express();
33
+ const server = http.createServer(app);
34
+ const wss = new WebSocketServer({ server, path: '/ws' });
35
+
36
+ const storage = new Storage(options.dbPath);
37
+ const dispatcher = new Dispatcher(storage, {
38
+ defaultConcurrency: options.defaultConcurrency || 5,
39
+ defaultTimeoutMs: options.defaultTimeoutMs || 25000
40
+ });
41
+
42
+ const worker = new RetryWorker(storage, dispatcher, {
43
+ intervalMs: options.retryIntervalMs || 5000
44
+ });
45
+ if (options.autoStartWorker !== false) {
46
+ worker.start();
47
+ }
48
+
49
+ server.on('close', () => {
50
+ worker.stop();
51
+ });
52
+
53
+ // Broadcast helper for real-time WebSocket dashboard
54
+ function broadcast(type, data) {
55
+ const payload = JSON.stringify({ type, data });
56
+ wss.clients.forEach(client => {
57
+ if (client.readyState === WebSocket.OPEN) {
58
+ client.send(payload);
59
+ }
60
+ });
61
+ }
62
+
63
+ // Hook dispatcher events to WebSocket broadcasts
64
+ dispatcher.on('delivery', (res) => {
65
+ broadcast('delivery', res);
66
+ });
67
+
68
+ // Enable CORS
69
+ app.use(cors());
70
+
71
+ // Serve static UI assets
72
+ app.use('/static', express.static(path.join(__dirname, 'public')));
73
+
74
+ // Ingress endpoint for webhooks - uses raw body parser to preserve raw bytes
75
+ app.post('/in/:endpointId', express.raw({ type: '*/*', limit: '10mb' }), async (req, res) => {
76
+ const endpointId = req.params.endpointId;
77
+ const endpoint = storage.getEndpoint(endpointId);
78
+
79
+ if (!endpoint) {
80
+ return res.status(404).json({ error: `HookArmor endpoint not found: ${endpointId}` });
81
+ }
82
+
83
+ const rawBodyBuffer = req.body || Buffer.from('');
84
+ const rawBody = rawBodyBuffer.toString('utf8');
85
+ const headers = { ...req.headers };
86
+
87
+ // Detect provider & idempotency key
88
+ let provider = 'generic';
89
+ let eventType = null;
90
+ let idempotencyKey = headers['idempotency-key'] || headers['x-idempotency-key'] || null;
91
+
92
+ if (headers['stripe-signature']) {
93
+ provider = 'stripe';
94
+ try {
95
+ const parsed = JSON.parse(rawBody);
96
+ eventType = parsed.type;
97
+ if (parsed.id) idempotencyKey = parsed.id;
98
+ } catch (e) {}
99
+ } else if (headers['x-shopify-hmac-sha256'] || headers['x-shopify-topic']) {
100
+ provider = 'shopify';
101
+ eventType = headers['x-shopify-topic'];
102
+ if (headers['x-shopify-webhook-id']) idempotencyKey = headers['x-shopify-webhook-id'];
103
+ } else if (headers['x-github-event']) {
104
+ provider = 'github';
105
+ eventType = headers['x-github-event'];
106
+ if (headers['x-github-delivery']) idempotencyKey = headers['x-github-delivery'];
107
+ } else if (headers['clerk-signature'] || headers['svix-id']) {
108
+ provider = 'clerk/svix';
109
+ if (headers['svix-id']) idempotencyKey = headers['svix-id'];
110
+ try {
111
+ const parsed = JSON.parse(rawBody);
112
+ eventType = parsed.type;
113
+ } catch (e) {}
114
+ } else {
115
+ try {
116
+ const parsed = JSON.parse(rawBody);
117
+ eventType = parsed.event || parsed.type || parsed.action || null;
118
+ if (parsed.id) idempotencyKey = String(parsed.id);
119
+ } catch (e) {}
120
+ }
121
+
122
+ // 0. Verify signature on ingress if endpoint secret is configured
123
+ if (endpoint.secret) {
124
+ const verification = Dispatcher.verifyIngressSignature(provider, rawBody, headers, endpoint.secret);
125
+ if (!verification.valid) {
126
+ return res.status(400).json({
127
+ error: 'Webhook signature verification failed',
128
+ provider,
129
+ reason: verification.reason
130
+ });
131
+ }
132
+ }
133
+
134
+ // Deduplication check: if this event ID was already delivered, ack immediately without duplicating downstream side effects
135
+ if (idempotencyKey) {
136
+ const existing = storage.findEventByIdempotencyKey(endpoint.id, idempotencyKey);
137
+ if (existing && existing.status === 'delivered') {
138
+ return res.status(200).json({
139
+ received: true,
140
+ hookarmor_id: existing.id,
141
+ status: 'deduplicated',
142
+ message: `Idempotency key ${idempotencyKey} already delivered. Suppressed duplicate execution.`
143
+ });
144
+ }
145
+ }
146
+
147
+ const eventId = `evt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
148
+
149
+ // 1. Durably save event in SQLite
150
+ const savedEvent = storage.saveEvent({
151
+ id: eventId,
152
+ endpointId: endpoint.id,
153
+ idempotencyKey,
154
+ provider,
155
+ eventType,
156
+ headers,
157
+ rawBody,
158
+ status: 'pending'
159
+ });
160
+
161
+ broadcast('event:received', {
162
+ id: eventId,
163
+ endpointId: endpoint.id,
164
+ provider,
165
+ eventType,
166
+ createdAt: savedEvent.created_at
167
+ });
168
+
169
+ // 2. Respond immediately with 200 OK to the sender so Stripe never times out or backs off
170
+ res.status(200).json({
171
+ received: true,
172
+ hookarmor_id: eventId,
173
+ status: 'buffered'
174
+ });
175
+
176
+ // 3. Dispatch to target destination asynchronously
177
+ setImmediate(async () => {
178
+ try {
179
+ await dispatcher.dispatch(savedEvent, endpoint);
180
+ } catch (err) {
181
+ console.error(`[Ingress] Dispatch error for ${eventId}:`, err);
182
+ }
183
+ });
184
+ });
185
+
186
+ // REST API: JSON Body Parser for Dashboard / Management
187
+ app.use(express.json());
188
+
189
+ // Management API Authentication Middleware (optional, active when HOOKARMOR_API_KEY is configured)
190
+ const apiKey = options.apiKey || process.env.HOOKARMOR_API_KEY || null;
191
+ app.use('/api', (req, res, next) => {
192
+ // Keep public waitlist signup open for landing page
193
+ if (req.path === '/waitlist' && req.method === 'POST') return next();
194
+
195
+ // Allow public read-only demo access & replay simulation so prospective users can explore live
196
+ const isPublicDemo =
197
+ (req.method === 'GET' && (req.path === '/stats' || req.path === '/endpoints' || req.path === '/events' || /^\/events\/[^\/]+$/.test(req.path))) ||
198
+ (req.method === 'POST' && (req.path === '/events/replay-all' || /^\/events\/[^\/]+\/replay$/.test(req.path)));
199
+
200
+ if (isPublicDemo) return next();
201
+
202
+ if (!apiKey) return next();
203
+
204
+ // Accept API token strictly via Authorization or X-Api-Key headers (NEVER query string URL)
205
+ const authHeader = req.headers['authorization'] || '';
206
+ const token = authHeader.replace(/^Bearer\s+/i, '').trim() || req.headers['x-api-key'] || '';
207
+ if (!token || token !== apiKey) {
208
+ return res.status(401).json({ error: 'Unauthorized: valid HookArmor API key required in header' });
209
+ }
210
+ next();
211
+ });
212
+
213
+ // Get Stats
214
+ app.get('/api/stats', (req, res) => {
215
+ res.json(storage.getStats());
216
+ });
217
+
218
+ // List Endpoints
219
+ app.get('/api/endpoints', (req, res) => {
220
+ res.json(storage.listEndpoints());
221
+ });
222
+
223
+ // Create or Update Endpoint
224
+ app.post('/api/endpoints', (req, res) => {
225
+ const { id, name, targetUrl, secret, alertWebhookUrl, autoRetry, maxRetries, concurrencyLimit, concurrency_limit } = req.body;
226
+ if (!id || !name || !targetUrl) {
227
+ return res.status(400).json({ error: 'id, name, and targetUrl are required' });
228
+ }
229
+ if (isPrivateOrMetadataUrl(targetUrl)) {
230
+ return res.status(400).json({ error: 'targetUrl cannot target cloud metadata or link-local addresses (SSRF blocked)' });
231
+ }
232
+ const endpoint = storage.createEndpoint({
233
+ id,
234
+ name,
235
+ targetUrl,
236
+ secret,
237
+ alertWebhookUrl,
238
+ autoRetry: autoRetry !== undefined ? autoRetry : 1,
239
+ maxRetries: maxRetries || 5,
240
+ concurrencyLimit: concurrencyLimit || concurrency_limit || 5
241
+ });
242
+ res.json(endpoint);
243
+ });
244
+
245
+ // List Events
246
+ app.get('/api/events', (req, res) => {
247
+ const { endpointId, status, limit, offset } = req.query;
248
+ const events = storage.listEvents({
249
+ endpointId,
250
+ status,
251
+ limit: parseInt(limit, 10) || 50,
252
+ offset: parseInt(offset, 10) || 0
253
+ });
254
+ res.json(events);
255
+ });
256
+
257
+ // Get single event with attempts
258
+ app.get('/api/events/:id', (req, res) => {
259
+ const event = storage.getEvent(req.params.id);
260
+ if (!event) return res.status(404).json({ error: 'Event not found' });
261
+ const attempts = storage.getAttempts(req.params.id);
262
+ res.json({ ...event, attempts });
263
+ });
264
+
265
+ // Replay a specific event
266
+ app.post('/api/events/:id/replay', async (req, res) => {
267
+ try {
268
+ const result = await dispatcher.replayEvent(req.params.id);
269
+ res.json(result);
270
+ } catch (err) {
271
+ res.status(500).json({ error: err.message });
272
+ }
273
+ });
274
+
275
+ // Replay all failed events (optionally filtered by endpoint)
276
+ app.post('/api/events/replay-all', async (req, res) => {
277
+ try {
278
+ const { endpointId } = req.body || {};
279
+ const results = await dispatcher.replayAllFailed(endpointId);
280
+ res.json({ replayedCount: results.length, results });
281
+ } catch (err) {
282
+ res.status(500).json({ error: err.message });
283
+ }
284
+ });
285
+
286
+ // Waitlist Registration
287
+ app.post('/api/waitlist', (req, res) => {
288
+ const { email, source } = req.body || {};
289
+ if (!email || !email.includes('@')) {
290
+ return res.status(400).json({ error: 'Valid email required' });
291
+ }
292
+ const result = storage.addWaitlist(email.trim().toLowerCase(), source || 'landing_page');
293
+ res.json({ success: true, message: 'Added to HookArmor Cloud beta waitlist!', email: result.email });
294
+ });
295
+
296
+ app.get('/api/waitlist', (req, res) => {
297
+ res.json(storage.listWaitlist());
298
+ });
299
+
300
+ // Mock Target Receiver (for self-testing and local simulation)
301
+ let mockTargetBehavior = {
302
+ statusCode: 200,
303
+ delayMs: 15,
304
+ responseBody: { status: 'mock_processed' }
305
+ };
306
+
307
+ app.post('/mock/target', (req, res) => {
308
+ setTimeout(() => {
309
+ res.status(mockTargetBehavior.statusCode).json(mockTargetBehavior.responseBody);
310
+ }, mockTargetBehavior.delayMs);
311
+ });
312
+
313
+ app.post('/mock/config', (req, res) => {
314
+ mockTargetBehavior = { ...mockTargetBehavior, ...req.body };
315
+ res.json({ message: 'Mock target configuration updated', config: mockTargetBehavior });
316
+ });
317
+
318
+ app.get('/mock/config', (req, res) => {
319
+ res.json(mockTargetBehavior);
320
+ });
321
+
322
+ // SEO & Web Crawler Discovery
323
+ app.get('/robots.txt', (req, res) => {
324
+ res.type('text/plain');
325
+ res.sendFile(path.join(__dirname, 'public', 'robots.txt'));
326
+ });
327
+
328
+ app.get('/sitemap.xml', (req, res) => {
329
+ res.type('application/xml');
330
+ res.sendFile(path.join(__dirname, 'public', 'sitemap.xml'));
331
+ });
332
+
333
+ app.get('/og-preview.png', (req, res) => {
334
+ res.type('image/png');
335
+ res.sendFile(path.join(__dirname, 'public', 'og-preview.png'));
336
+ });
337
+
338
+ // Landing Page Route
339
+ app.get('/', (req, res) => {
340
+ res.sendFile(path.join(__dirname, 'public', 'landing.html'));
341
+ });
342
+
343
+ // Interactive Dashboard Routes
344
+ app.get(['/dashboard', '/app'], (req, res) => {
345
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
346
+ });
347
+
348
+ // Engineering Blog Routes
349
+ app.get('/blog', (req, res) => {
350
+ res.sendFile(path.join(__dirname, 'public', 'blog', 'index.html'));
351
+ });
352
+
353
+ app.get('/blog/:slug', (req, res) => {
354
+ const slug = req.params.slug.replace(/[^a-zA-Z0-9-_]/g, '');
355
+ const filePath = path.join(__dirname, 'public', 'blog', `${slug}.html`);
356
+ if (fs.existsSync(filePath)) {
357
+ return res.sendFile(filePath);
358
+ }
359
+ res.redirect('/blog');
360
+ });
361
+
362
+ return { app, server, storage, dispatcher, worker };
363
+ }
364
+
365
+ module.exports = { createServer };
package/src/storage.js ADDED
@@ -0,0 +1,266 @@
1
+ const Database = require('better-sqlite3');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ class Storage {
6
+ constructor(dbPath) {
7
+ if (!dbPath) {
8
+ const dataDir = path.join(__dirname, '..', 'data');
9
+ if (!fs.existsSync(dataDir)) {
10
+ fs.mkdirSync(dataDir, { recursive: true });
11
+ }
12
+ dbPath = path.join(dataDir, 'hookarmor.db');
13
+ }
14
+ this.db = new Database(dbPath);
15
+ this.init();
16
+ }
17
+
18
+ init() {
19
+ try {
20
+ this.db.pragma('journal_mode = WAL');
21
+ this.db.pragma('synchronous = NORMAL');
22
+ } catch (e) {}
23
+
24
+ this.db.exec(`
25
+ CREATE TABLE IF NOT EXISTS endpoints (
26
+ id TEXT PRIMARY KEY,
27
+ name TEXT NOT NULL,
28
+ target_url TEXT NOT NULL,
29
+ secret TEXT,
30
+ alert_webhook_url TEXT,
31
+ auto_retry INTEGER DEFAULT 1,
32
+ max_retries INTEGER DEFAULT 5,
33
+ concurrency_limit INTEGER DEFAULT 5,
34
+ created_at TEXT DEFAULT (datetime('now'))
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS events (
38
+ id TEXT PRIMARY KEY,
39
+ endpoint_id TEXT NOT NULL,
40
+ idempotency_key TEXT,
41
+ provider TEXT DEFAULT 'generic',
42
+ event_type TEXT,
43
+ headers TEXT NOT NULL,
44
+ raw_body TEXT NOT NULL,
45
+ status TEXT NOT NULL, -- 'pending', 'delivered', 'failed', 'replaying'
46
+ attempts INTEGER DEFAULT 0,
47
+ last_status_code INTEGER,
48
+ last_error TEXT,
49
+ last_latency_ms INTEGER,
50
+ next_retry_at TEXT,
51
+ created_at TEXT DEFAULT (datetime('now')),
52
+ updated_at TEXT DEFAULT (datetime('now')),
53
+ FOREIGN KEY (endpoint_id) REFERENCES endpoints(id)
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS delivery_attempts (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ event_id TEXT NOT NULL,
59
+ status_code INTEGER,
60
+ response_body TEXT,
61
+ error_message TEXT,
62
+ latency_ms INTEGER,
63
+ attempted_at TEXT DEFAULT (datetime('now')),
64
+ FOREIGN KEY (event_id) REFERENCES events(id)
65
+ );
66
+
67
+ CREATE TABLE IF NOT EXISTS waitlist (
68
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
69
+ email TEXT UNIQUE NOT NULL,
70
+ source TEXT DEFAULT 'website',
71
+ created_at TEXT DEFAULT (datetime('now'))
72
+ );
73
+
74
+ CREATE INDEX IF NOT EXISTS idx_events_status ON events(status);
75
+ CREATE INDEX IF NOT EXISTS idx_events_endpoint ON events(endpoint_id);
76
+ CREATE INDEX IF NOT EXISTS idx_events_idempotency ON events(endpoint_id, idempotency_key);
77
+ CREATE INDEX IF NOT EXISTS idx_events_retry ON events(status, next_retry_at);
78
+ CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at);
79
+ `);
80
+ }
81
+
82
+ createEndpoint({ id, name, targetUrl, secret = '', alertWebhookUrl = '', autoRetry = 1, maxRetries = 5, concurrencyLimit = 5 }) {
83
+ const stmt = this.db.prepare(`
84
+ INSERT OR REPLACE INTO endpoints (id, name, target_url, secret, alert_webhook_url, auto_retry, max_retries, concurrency_limit)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
86
+ `);
87
+ stmt.run(id, name, targetUrl, secret, alertWebhookUrl, autoRetry ? 1 : 0, maxRetries, concurrencyLimit || 5);
88
+ return this.getEndpoint(id);
89
+ }
90
+
91
+ getEndpoint(id) {
92
+ return this.db.prepare('SELECT * FROM endpoints WHERE id = ?').get(id);
93
+ }
94
+
95
+ listEndpoints() {
96
+ return this.db.prepare('SELECT * FROM endpoints ORDER BY created_at DESC').all();
97
+ }
98
+
99
+ findEventByIdempotencyKey(endpointId, idempotencyKey) {
100
+ if (!idempotencyKey) return null;
101
+ const row = this.db.prepare('SELECT * FROM events WHERE endpoint_id = ? AND idempotency_key = ?').get(endpointId, idempotencyKey);
102
+ if (!row) return null;
103
+ return { ...row, headers: JSON.parse(row.headers) };
104
+ }
105
+
106
+ saveEvent({ id, endpointId, idempotencyKey = null, provider, eventType, headers, rawBody, status = 'pending' }) {
107
+ const stmt = this.db.prepare(`
108
+ INSERT INTO events (id, endpoint_id, idempotency_key, provider, event_type, headers, raw_body, status, attempts, created_at, updated_at)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, datetime('now'), datetime('now'))
110
+ `);
111
+ stmt.run(
112
+ id,
113
+ endpointId,
114
+ idempotencyKey,
115
+ provider,
116
+ eventType,
117
+ JSON.stringify(headers),
118
+ rawBody,
119
+ status
120
+ );
121
+ return this.getEvent(id);
122
+ }
123
+
124
+ recordAttempt({ eventId, statusCode, responseBody = '', errorMessage = '', latencyMs = 0, nextRetryAt = null }) {
125
+ const insertAttempt = this.db.prepare(`
126
+ INSERT INTO delivery_attempts (event_id, status_code, response_body, error_message, latency_ms, attempted_at)
127
+ VALUES (?, ?, ?, ?, ?, datetime('now'))
128
+ `);
129
+ insertAttempt.run(eventId, statusCode, responseBody.slice(0, 4000), errorMessage.slice(0, 1000), latencyMs);
130
+
131
+ const isSuccess = statusCode >= 200 && statusCode < 300;
132
+ const newStatus = isSuccess ? 'delivered' : 'failed';
133
+
134
+ const updateEvent = this.db.prepare(`
135
+ UPDATE events
136
+ SET status = ?,
137
+ attempts = attempts + 1,
138
+ last_status_code = ?,
139
+ last_error = ?,
140
+ last_latency_ms = ?,
141
+ next_retry_at = ?,
142
+ updated_at = datetime('now')
143
+ WHERE id = ?
144
+ `);
145
+ updateEvent.run(newStatus, statusCode, errorMessage || (isSuccess ? null : `HTTP ${statusCode}`), latencyMs, nextRetryAt, eventId);
146
+
147
+ return this.getEvent(eventId);
148
+ }
149
+
150
+ getEvent(id) {
151
+ const row = this.db.prepare('SELECT * FROM events WHERE id = ?').get(id);
152
+ if (!row) return null;
153
+ return {
154
+ ...row,
155
+ headers: JSON.parse(row.headers)
156
+ };
157
+ }
158
+
159
+ getAttempts(eventId) {
160
+ return this.db.prepare('SELECT * FROM delivery_attempts WHERE event_id = ? ORDER BY attempted_at DESC').all(eventId);
161
+ }
162
+
163
+ listEvents({ endpointId = null, status = null, limit = 50, offset = 0 } = {}) {
164
+ let sql = 'SELECT * FROM events WHERE 1=1';
165
+ const params = [];
166
+
167
+ if (endpointId) {
168
+ sql += ' AND endpoint_id = ?';
169
+ params.push(endpointId);
170
+ }
171
+ if (status) {
172
+ sql += ' AND status = ?';
173
+ params.push(status);
174
+ }
175
+ sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
176
+ params.push(limit, offset);
177
+
178
+ const rows = this.db.prepare(sql).all(...params);
179
+ return rows.map(r => ({
180
+ ...r,
181
+ headers: JSON.parse(r.headers)
182
+ }));
183
+ }
184
+
185
+ getStats() {
186
+ const total = this.db.prepare('SELECT count(*) as count FROM events').get().count;
187
+ const delivered = this.db.prepare("SELECT count(*) as count FROM events WHERE status = 'delivered'").get().count;
188
+ const failed = this.db.prepare("SELECT count(*) as count FROM events WHERE status = 'failed'").get().count;
189
+ const pending = this.db.prepare("SELECT count(*) as count FROM events WHERE status = 'pending'").get().count;
190
+ const avgLatency = this.db.prepare('SELECT avg(last_latency_ms) as avg_lat FROM events WHERE last_latency_ms IS NOT NULL').get().avg_lat || 0;
191
+
192
+ return {
193
+ total,
194
+ delivered,
195
+ failed,
196
+ pending,
197
+ avgLatencyMs: Math.round(avgLatency)
198
+ };
199
+ }
200
+
201
+ getFailedEventsToRetry(endpointId = null) {
202
+ let sql = `
203
+ SELECT e.*, ep.max_retries, ep.target_url, ep.secret, ep.alert_webhook_url
204
+ FROM events e
205
+ JOIN endpoints ep ON e.endpoint_id = ep.id
206
+ WHERE e.status = 'failed' AND ep.auto_retry = 1 AND e.attempts < ep.max_retries
207
+ `;
208
+ const params = [];
209
+ if (endpointId) {
210
+ sql += ' AND e.endpoint_id = ?';
211
+ params.push(endpointId);
212
+ }
213
+ sql += ' ORDER BY e.created_at ASC';
214
+ const rows = this.db.prepare(sql).all(...params);
215
+ return rows.map(r => ({
216
+ ...r,
217
+ headers: JSON.parse(r.headers)
218
+ }));
219
+ }
220
+
221
+ getEventsDueForRetry(limit = 25) {
222
+ const sql = `
223
+ SELECT e.*, ep.max_retries, ep.target_url, ep.secret, ep.alert_webhook_url
224
+ FROM events e
225
+ JOIN endpoints ep ON e.endpoint_id = ep.id
226
+ WHERE (
227
+ (e.status = 'failed' AND ep.auto_retry = 1 AND e.attempts < ep.max_retries AND e.next_retry_at IS NOT NULL AND e.next_retry_at <= datetime('now'))
228
+ OR
229
+ (e.status = 'pending' AND ep.auto_retry = 1 AND e.created_at <= datetime('now', '-20 seconds'))
230
+ )
231
+ ORDER BY e.created_at ASC
232
+ LIMIT ?
233
+ `;
234
+ const rows = this.db.prepare(sql).all(limit);
235
+ return rows.map(r => ({
236
+ ...r,
237
+ headers: JSON.parse(r.headers)
238
+ }));
239
+ }
240
+
241
+ claimEventForRetry(eventId) {
242
+ const stmt = this.db.prepare(`
243
+ UPDATE events
244
+ SET status = 'replaying', updated_at = datetime('now')
245
+ WHERE id = ? AND status IN ('failed', 'pending')
246
+ `);
247
+ const info = stmt.run(eventId);
248
+ return info.changes > 0;
249
+ }
250
+
251
+ addWaitlist(email, source = 'website') {
252
+ const stmt = this.db.prepare(`
253
+ INSERT INTO waitlist (email, source, created_at)
254
+ VALUES (?, ?, datetime('now'))
255
+ ON CONFLICT(email) DO UPDATE SET created_at = datetime('now')
256
+ `);
257
+ stmt.run(email, source);
258
+ return { email, source, status: 'registered' };
259
+ }
260
+
261
+ listWaitlist() {
262
+ return this.db.prepare('SELECT * FROM waitlist ORDER BY created_at DESC').all();
263
+ }
264
+ }
265
+
266
+ module.exports = Storage;
package/src/worker.js ADDED
@@ -0,0 +1,62 @@
1
+ const EventEmitter = require('events');
2
+
3
+ class RetryWorker extends EventEmitter {
4
+ constructor(storage, dispatcher, options = {}) {
5
+ super();
6
+ this.storage = storage;
7
+ this.dispatcher = dispatcher;
8
+ this.intervalMs = options.intervalMs || 5000;
9
+ this.timer = null;
10
+ this.isProcessing = false;
11
+ }
12
+
13
+ start() {
14
+ if (this.timer) return;
15
+ this.timer = setInterval(() => this.tick(), this.intervalMs);
16
+ // Unref timer so it doesn't hold Node process open during graceful shutdown or tests if desired
17
+ if (this.timer.unref) this.timer.unref();
18
+ this.emit('started');
19
+ }
20
+
21
+ stop() {
22
+ if (this.timer) {
23
+ clearInterval(this.timer);
24
+ this.timer = null;
25
+ this.emit('stopped');
26
+ }
27
+ }
28
+
29
+ async tick() {
30
+ if (this.isProcessing) return;
31
+ this.isProcessing = true;
32
+
33
+ try {
34
+ const eventsDue = this.storage.getEventsDueForRetry(25);
35
+ if (!eventsDue || eventsDue.length === 0) {
36
+ this.isProcessing = false;
37
+ return;
38
+ }
39
+
40
+ for (const event of eventsDue) {
41
+ const claimed = this.storage.claimEventForRetry(event.id);
42
+ if (!claimed) continue; // Another process or manual replay already claimed it
43
+
44
+ const endpoint = this.storage.getEndpoint(event.endpoint_id);
45
+ if (!endpoint) continue;
46
+
47
+ this.emit('retry:claimed', { eventId: event.id, endpointId: endpoint.id });
48
+
49
+ // Dispatch asynchronously without blocking next iterations
50
+ this.dispatcher.dispatch(event, endpoint).catch((err) => {
51
+ this.emit('retry:error', { eventId: event.id, error: err.message });
52
+ });
53
+ }
54
+ } catch (err) {
55
+ this.emit('error', err);
56
+ } finally {
57
+ this.isProcessing = false;
58
+ }
59
+ }
60
+ }
61
+
62
+ module.exports = RetryWorker;