webhook-delivery-engine 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +111 -2
- package/dist/src/app/bootstrap.d.ts +14 -0
- package/dist/src/app/bootstrap.js +13 -0
- package/dist/src/app/embedded.d.ts +45 -0
- package/dist/src/app/embedded.js +228 -0
- package/dist/src/app/http.server.d.ts +10 -0
- package/dist/src/app/http.server.js +65 -0
- package/dist/src/app/lifecycle.d.ts +108 -0
- package/dist/src/app/lifecycle.js +138 -0
- package/dist/src/config/engine.config.d.ts +45 -0
- package/dist/src/config/engine.config.js +1 -0
- package/dist/src/core/errors.d.ts +4 -0
- package/dist/src/core/errors.js +7 -0
- package/dist/src/core/ports.d.ts +217 -0
- package/dist/src/core/ports.js +1 -0
- package/dist/src/core/types.d.ts +98 -0
- package/dist/src/core/types.js +10 -0
- package/dist/src/features/delivery/delivery.backoff.d.ts +74 -0
- package/dist/src/features/delivery/delivery.backoff.js +56 -0
- package/dist/src/features/delivery/delivery.classify.d.ts +28 -0
- package/dist/src/features/delivery/delivery.classify.js +38 -0
- package/dist/src/features/delivery/delivery.dispatcher.d.ts +57 -0
- package/dist/src/features/delivery/delivery.dispatcher.js +155 -0
- package/dist/src/features/delivery/delivery.hmac.d.ts +62 -0
- package/dist/src/features/delivery/delivery.hmac.js +31 -0
- package/dist/src/features/delivery/delivery.worker.d.ts +60 -0
- package/dist/src/features/delivery/delivery.worker.js +136 -0
- package/dist/src/features/http/http.responses.d.ts +6 -0
- package/dist/src/features/http/http.responses.js +45 -0
- package/dist/src/features/ingestion/ingestion.handler.d.ts +81 -0
- package/dist/src/features/ingestion/ingestion.handler.js +59 -0
- package/dist/src/features/ingestion/ingestion.routes.d.ts +12 -0
- package/dist/src/features/ingestion/ingestion.routes.js +25 -0
- package/dist/src/features/ingestion/ingestion.schema.d.ts +26 -0
- package/dist/src/features/ingestion/ingestion.schema.js +24 -0
- package/dist/src/features/query/query.routes.d.ts +11 -0
- package/dist/src/features/query/query.routes.js +99 -0
- package/dist/src/features/scheduler/scheduler.d.ts +110 -0
- package/dist/src/features/scheduler/scheduler.js +153 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +1 -0
- package/dist/src/security/auth.d.ts +40 -0
- package/dist/src/security/auth.js +39 -0
- package/dist/src/security/ssrf.d.ts +47 -0
- package/dist/src/security/ssrf.js +173 -0
- package/dist/src/shared/logger.d.ts +11 -0
- package/dist/src/shared/logger.js +52 -0
- package/dist/src/storage/migrations/runner.d.ts +51 -0
- package/dist/src/storage/migrations/runner.js +155 -0
- package/dist/src/storage/migrations/sql/sqlite/001_initial_schema.sql +52 -0
- package/dist/src/storage/sqlite.adapter.d.ts +51 -0
- package/dist/src/storage/sqlite.adapter.js +478 -0
- package/package.json +76 -4
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
import { ATTEMPT_OUTCOMES, EVENT_STATUSES } from '../core/types.js';
|
|
4
|
+
import { InvalidPageCursorError } from '../core/errors.js';
|
|
5
|
+
import { runMigrations, } from './migrations/runner.js';
|
|
6
|
+
const MAX_PAGE_SIZE = 1000;
|
|
7
|
+
const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
8
|
+
function createMigrationConnection(database) {
|
|
9
|
+
return {
|
|
10
|
+
exec: (sql) => {
|
|
11
|
+
database.exec(sql);
|
|
12
|
+
},
|
|
13
|
+
prepare: (sql) => {
|
|
14
|
+
const statement = database.prepare(sql);
|
|
15
|
+
return {
|
|
16
|
+
all: (...parameters) => statement.all(...parameters),
|
|
17
|
+
get: (...parameters) => statement.get(...parameters),
|
|
18
|
+
run: (...parameters) => statement.run(...parameters),
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* SQLite implementation of the driver-neutral event persistence contract.
|
|
25
|
+
*
|
|
26
|
+
* Create instances with {@link SqliteDatabaseAdapter.open}; the returned
|
|
27
|
+
* adapter owns its database connection and must be closed by its owner.
|
|
28
|
+
*/
|
|
29
|
+
export class SqliteDatabaseAdapter {
|
|
30
|
+
database;
|
|
31
|
+
constructor(database) {
|
|
32
|
+
this.database = database;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Opens and initializes a SQLite database before exposing the adapter.
|
|
36
|
+
*
|
|
37
|
+
* Initialization enables WAL, a 5-second busy timeout, foreign-key checks,
|
|
38
|
+
* and applies the bundled forward-only migrations. The connection is closed
|
|
39
|
+
* and the original error is propagated if initialization fails.
|
|
40
|
+
*
|
|
41
|
+
* @param filename - SQLite database file path; use a file for WAL support.
|
|
42
|
+
* @returns An initialized adapter implementing {@link DatabaseAdapter}.
|
|
43
|
+
* @throws When the database cannot be opened, configured, or migrated.
|
|
44
|
+
*/
|
|
45
|
+
static async open(filename) {
|
|
46
|
+
const database = new Database(filename);
|
|
47
|
+
try {
|
|
48
|
+
const journalMode = database.pragma('journal_mode = WAL', { simple: true });
|
|
49
|
+
if (journalMode !== 'wal') {
|
|
50
|
+
throw new Error('SQLite did not enable the required WAL journal mode.');
|
|
51
|
+
}
|
|
52
|
+
const busyTimeout = database.pragma('busy_timeout = 5000', { simple: true });
|
|
53
|
+
if (busyTimeout !== 5000) {
|
|
54
|
+
throw new Error('SQLite did not apply the required 5-second busy timeout.');
|
|
55
|
+
}
|
|
56
|
+
database.pragma('foreign_keys = ON');
|
|
57
|
+
const foreignKeys = database.pragma('foreign_keys', { simple: true });
|
|
58
|
+
if (foreignKeys !== 1) {
|
|
59
|
+
throw new Error('SQLite did not enable foreign-key enforcement.');
|
|
60
|
+
}
|
|
61
|
+
await runMigrations({
|
|
62
|
+
dialect: 'sqlite',
|
|
63
|
+
connection: createMigrationConnection(database),
|
|
64
|
+
});
|
|
65
|
+
return new SqliteDatabaseAdapter(database);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
database.close();
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Closes the SQLite connection owned by this adapter.
|
|
74
|
+
*
|
|
75
|
+
* @returns Nothing.
|
|
76
|
+
* @throws When the underlying database cannot be closed.
|
|
77
|
+
*/
|
|
78
|
+
close() {
|
|
79
|
+
this.database.close();
|
|
80
|
+
}
|
|
81
|
+
/** @inheritdoc */
|
|
82
|
+
async upsertEvent(event) {
|
|
83
|
+
const createdAt = normalizeTimestamp(event.createdAt, 'createdAt');
|
|
84
|
+
const updatedAt = normalizeTimestamp(event.updatedAt, 'updatedAt');
|
|
85
|
+
const nextRetryAt = nullableTimestamp(event.nextRetryAt, 'nextRetryAt');
|
|
86
|
+
const lockedAt = nullableTimestamp(event.lockedAt, 'lockedAt');
|
|
87
|
+
const result = this.database
|
|
88
|
+
.prepare(`INSERT INTO webhook_events (
|
|
89
|
+
id, idempotency_key, client_id, destination_url, payload, metadata,
|
|
90
|
+
headers, status, attempt_count, next_retry_at, locked_at, locked_by,
|
|
91
|
+
created_at, updated_at
|
|
92
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
93
|
+
ON CONFLICT (client_id, idempotency_key) DO NOTHING`)
|
|
94
|
+
.run(event.id, event.idempotencyKey, event.clientId, event.destinationUrl, serializeJson(event.payload, 'payload'), serializeJsonObject(event.metadata, 'metadata'), serializeHeaders(event.headers), event.status, event.attemptCount, nextRetryAt, lockedAt, event.lockedBy, createdAt, updatedAt);
|
|
95
|
+
const row = this.database
|
|
96
|
+
.prepare('SELECT * FROM webhook_events WHERE client_id = ? AND idempotency_key = ?')
|
|
97
|
+
.get(event.clientId, event.idempotencyKey);
|
|
98
|
+
if (row === undefined) {
|
|
99
|
+
throw new Error('The event upsert completed without a matching event row.');
|
|
100
|
+
}
|
|
101
|
+
const storedEvent = readEvent(row);
|
|
102
|
+
return { event: storedEvent, inserted: result.changes === 1 };
|
|
103
|
+
}
|
|
104
|
+
/** @inheritdoc */
|
|
105
|
+
async claimDueEvents(input) {
|
|
106
|
+
assertPositiveSafeInteger(input.limit, 'limit');
|
|
107
|
+
const now = normalizeTimestamp(input.now, 'now');
|
|
108
|
+
const claimedEvents = this.database
|
|
109
|
+
.prepare(`UPDATE webhook_events
|
|
110
|
+
SET status = 'processing', locked_at = ?, locked_by = ?,
|
|
111
|
+
attempt_count = attempt_count + 1, updated_at = ?
|
|
112
|
+
WHERE id IN (
|
|
113
|
+
SELECT id FROM webhook_events
|
|
114
|
+
WHERE status IN ('pending', 'retry')
|
|
115
|
+
AND (next_retry_at IS NULL OR next_retry_at <= ?)
|
|
116
|
+
ORDER BY created_at ASC, id ASC
|
|
117
|
+
LIMIT ?
|
|
118
|
+
)
|
|
119
|
+
RETURNING *`)
|
|
120
|
+
.all(now, input.workerId, now, now, input.limit)
|
|
121
|
+
.map(readEvent);
|
|
122
|
+
return claimedEvents.sort(compareClaimedEvents);
|
|
123
|
+
}
|
|
124
|
+
/** @inheritdoc */
|
|
125
|
+
async recoverStaleLeases(input) {
|
|
126
|
+
const now = normalizeTimestamp(input.now, 'now');
|
|
127
|
+
if (!Number.isSafeInteger(input.timeoutLimitMs) || input.timeoutLimitMs <= 0) {
|
|
128
|
+
throw new RangeError('timeoutLimitMs must be a positive safe integer.');
|
|
129
|
+
}
|
|
130
|
+
const staleThresholdMs = Math.max(input.timeoutLimitMs + 30_000, 90_000);
|
|
131
|
+
if (!Number.isSafeInteger(staleThresholdMs)) {
|
|
132
|
+
throw new RangeError('timeoutLimitMs produces an unsafe stale lease threshold.');
|
|
133
|
+
}
|
|
134
|
+
const nowMilliseconds = Date.parse(now);
|
|
135
|
+
const staleBefore = normalizeTimestamp(new Date(nowMilliseconds - staleThresholdMs).toISOString(), 'staleBefore');
|
|
136
|
+
const nextRetryAt = normalizeTimestamp(new Date(nowMilliseconds + 10_000).toISOString(), 'nextRetryAt');
|
|
137
|
+
const result = this.database
|
|
138
|
+
.prepare(`UPDATE webhook_events
|
|
139
|
+
SET status = 'retry', next_retry_at = ?, locked_at = NULL,
|
|
140
|
+
locked_by = NULL, updated_at = ?
|
|
141
|
+
WHERE status = 'processing' AND locked_at < ?`)
|
|
142
|
+
.run(nextRetryAt, now, staleBefore);
|
|
143
|
+
return result.changes;
|
|
144
|
+
}
|
|
145
|
+
/** @inheritdoc */
|
|
146
|
+
async releaseEventLease(input) {
|
|
147
|
+
const releasedAt = normalizeTimestamp(input.releasedAt, 'releasedAt');
|
|
148
|
+
const result = this.database
|
|
149
|
+
.prepare(`UPDATE webhook_events
|
|
150
|
+
SET status = 'pending', next_retry_at = NULL, locked_at = NULL,
|
|
151
|
+
locked_by = NULL, attempt_count = attempt_count - 1, updated_at = ?
|
|
152
|
+
WHERE id = ? AND locked_by = ? AND status = 'processing'
|
|
153
|
+
AND attempt_count > 0`)
|
|
154
|
+
.run(releasedAt, input.eventId, input.workerId);
|
|
155
|
+
return result.changes === 1;
|
|
156
|
+
}
|
|
157
|
+
/** @inheritdoc */
|
|
158
|
+
async recordAttemptAndTransition(input) {
|
|
159
|
+
const { attempt, workerId, nextStatus, nextRetryAt, updatedAt } = input;
|
|
160
|
+
const attemptedAt = normalizeTimestamp(attempt.attemptedAt, 'attemptedAt');
|
|
161
|
+
const normalizedUpdatedAt = normalizeTimestamp(updatedAt, 'updatedAt');
|
|
162
|
+
const normalizedNextRetryAt = nullableTimestamp(nextRetryAt, 'nextRetryAt');
|
|
163
|
+
const transaction = this.database.transaction(() => {
|
|
164
|
+
const event = this.database
|
|
165
|
+
.prepare('SELECT attempt_count FROM webhook_events WHERE id = ? AND locked_by = ? AND status = ?')
|
|
166
|
+
.get(attempt.eventId, workerId, 'processing');
|
|
167
|
+
if (event === undefined)
|
|
168
|
+
return false;
|
|
169
|
+
const attemptCount = readNumberProperty(event, 'attempt_count');
|
|
170
|
+
if (attempt.attemptNumber !== attemptCount)
|
|
171
|
+
return false;
|
|
172
|
+
this.database
|
|
173
|
+
.prepare(`INSERT INTO webhook_event_attempts (
|
|
174
|
+
id, event_id, attempt_number, attempted_at, status_code,
|
|
175
|
+
response_snippet, error, latency_ms, outcome
|
|
176
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
177
|
+
.run(attempt.id, attempt.eventId, attempt.attemptNumber, attemptedAt, attempt.statusCode, attempt.responseSnippet, attempt.error, attempt.latencyMs, attempt.outcome);
|
|
178
|
+
const result = this.database
|
|
179
|
+
.prepare(`UPDATE webhook_events
|
|
180
|
+
SET status = ?, next_retry_at = ?, locked_at = NULL, locked_by = NULL,
|
|
181
|
+
updated_at = ?
|
|
182
|
+
WHERE id = ? AND locked_by = ? AND status = 'processing'`)
|
|
183
|
+
.run(nextStatus, normalizedNextRetryAt, normalizedUpdatedAt, attempt.eventId, workerId);
|
|
184
|
+
if (result.changes !== 1) {
|
|
185
|
+
throw new Error('The event lease changed while recording the attempt.');
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
});
|
|
189
|
+
return transaction.immediate();
|
|
190
|
+
}
|
|
191
|
+
/** @inheritdoc */
|
|
192
|
+
async findEventById(eventId, clientId) {
|
|
193
|
+
const row = this.database
|
|
194
|
+
.prepare('SELECT * FROM webhook_events WHERE id = ? AND client_id = ?')
|
|
195
|
+
.get(eventId, clientId);
|
|
196
|
+
return row === undefined ? null : readEvent(row);
|
|
197
|
+
}
|
|
198
|
+
/** @inheritdoc */
|
|
199
|
+
async findEventByIdempotencyKey(clientId, idempotencyKey) {
|
|
200
|
+
const row = this.database
|
|
201
|
+
.prepare('SELECT * FROM webhook_events WHERE client_id = ? AND idempotency_key = ?')
|
|
202
|
+
.get(clientId, idempotencyKey);
|
|
203
|
+
return row === undefined ? null : readEvent(row);
|
|
204
|
+
}
|
|
205
|
+
/** @inheritdoc */
|
|
206
|
+
async listEvents(input) {
|
|
207
|
+
assertPositiveSafeInteger(input.limit, 'limit');
|
|
208
|
+
if (input.limit > MAX_PAGE_SIZE) {
|
|
209
|
+
throw new RangeError(`limit must not exceed ${MAX_PAGE_SIZE}.`);
|
|
210
|
+
}
|
|
211
|
+
const parameters = [input.clientId];
|
|
212
|
+
const filters = ['client_id = ?'];
|
|
213
|
+
if (input.status !== undefined) {
|
|
214
|
+
filters.push('status = ?');
|
|
215
|
+
parameters.push(input.status);
|
|
216
|
+
}
|
|
217
|
+
if (input.cursor !== undefined) {
|
|
218
|
+
const cursor = decodeCursor(input.cursor);
|
|
219
|
+
filters.push('(created_at > ? OR (created_at = ? AND id > ?))');
|
|
220
|
+
parameters.push(cursor.createdAt, cursor.createdAt, cursor.id);
|
|
221
|
+
}
|
|
222
|
+
const rows = this.database
|
|
223
|
+
.prepare(`SELECT * FROM webhook_events
|
|
224
|
+
WHERE ${filters.join(' AND ')}
|
|
225
|
+
ORDER BY created_at, id
|
|
226
|
+
LIMIT ?`)
|
|
227
|
+
.all(...parameters, input.limit + 1);
|
|
228
|
+
const hasNextPage = rows.length > input.limit;
|
|
229
|
+
const items = rows.slice(0, input.limit).map(readEvent);
|
|
230
|
+
const lastItem = items.at(-1);
|
|
231
|
+
return {
|
|
232
|
+
items,
|
|
233
|
+
nextCursor: hasNextPage && lastItem !== undefined ? encodeCursor(lastItem) : null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** @inheritdoc */
|
|
237
|
+
async listAttempts(eventId, clientId) {
|
|
238
|
+
const rows = this.database
|
|
239
|
+
.prepare(`SELECT attempts.* FROM webhook_event_attempts AS attempts
|
|
240
|
+
INNER JOIN webhook_events AS events ON events.id = attempts.event_id
|
|
241
|
+
WHERE events.id = ? AND events.client_id = ?
|
|
242
|
+
ORDER BY attempts.attempt_number`)
|
|
243
|
+
.all(eventId, clientId);
|
|
244
|
+
return rows.map(readAttempt);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** Compares claimed events by creation time, then by their unique id. */
|
|
248
|
+
export function compareClaimedEvents(left, right) {
|
|
249
|
+
if (left.createdAt !== right.createdAt) {
|
|
250
|
+
return left.createdAt < right.createdAt ? -1 : 1;
|
|
251
|
+
}
|
|
252
|
+
if (left.id < right.id)
|
|
253
|
+
return -1;
|
|
254
|
+
if (left.id > right.id)
|
|
255
|
+
return 1;
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
function readEvent(value) {
|
|
259
|
+
const row = asRow(value, 'event');
|
|
260
|
+
const status = readStringProperty(row, 'status');
|
|
261
|
+
if (!EVENT_STATUSES.includes(status)) {
|
|
262
|
+
throw new Error('The database contains an invalid event status.');
|
|
263
|
+
}
|
|
264
|
+
const payload = parseJson(readStringProperty(row, 'payload'), 'payload');
|
|
265
|
+
const metadata = parseJsonObject(readStringProperty(row, 'metadata'), 'metadata');
|
|
266
|
+
const headers = parseHeaders(readStringProperty(row, 'headers'));
|
|
267
|
+
return {
|
|
268
|
+
id: readStringProperty(row, 'id'),
|
|
269
|
+
idempotencyKey: readStringProperty(row, 'idempotency_key'),
|
|
270
|
+
clientId: readStringProperty(row, 'client_id'),
|
|
271
|
+
destinationUrl: readStringProperty(row, 'destination_url'),
|
|
272
|
+
payload,
|
|
273
|
+
metadata,
|
|
274
|
+
headers,
|
|
275
|
+
status: status,
|
|
276
|
+
attemptCount: readNumberProperty(row, 'attempt_count'),
|
|
277
|
+
nextRetryAt: readNullableTimestamp(row, 'next_retry_at'),
|
|
278
|
+
lockedAt: readNullableTimestamp(row, 'locked_at'),
|
|
279
|
+
lockedBy: readNullableStringProperty(row, 'locked_by'),
|
|
280
|
+
createdAt: normalizeTimestamp(readStringProperty(row, 'created_at'), 'created_at'),
|
|
281
|
+
updatedAt: normalizeTimestamp(readStringProperty(row, 'updated_at'), 'updated_at'),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function readAttempt(value) {
|
|
285
|
+
const row = asRow(value, 'attempt');
|
|
286
|
+
const outcome = readStringProperty(row, 'outcome');
|
|
287
|
+
if (!ATTEMPT_OUTCOMES.includes(outcome)) {
|
|
288
|
+
throw new Error('The database contains an invalid attempt outcome.');
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
id: readStringProperty(row, 'id'),
|
|
292
|
+
eventId: readStringProperty(row, 'event_id'),
|
|
293
|
+
attemptNumber: readNumberProperty(row, 'attempt_number'),
|
|
294
|
+
attemptedAt: normalizeTimestamp(readStringProperty(row, 'attempted_at'), 'attempted_at'),
|
|
295
|
+
statusCode: readNullableNumberProperty(row, 'status_code'),
|
|
296
|
+
responseSnippet: readNullableStringProperty(row, 'response_snippet'),
|
|
297
|
+
error: readNullableStringProperty(row, 'error'),
|
|
298
|
+
latencyMs: readNullableNumberProperty(row, 'latency_ms'),
|
|
299
|
+
outcome: outcome,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function asRow(value, description) {
|
|
303
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
304
|
+
throw new Error(`The database returned an invalid ${description} row.`);
|
|
305
|
+
}
|
|
306
|
+
return value;
|
|
307
|
+
}
|
|
308
|
+
function readStringProperty(row, key) {
|
|
309
|
+
const value = asRow(row, 'database')[key];
|
|
310
|
+
if (typeof value !== 'string') {
|
|
311
|
+
throw new Error(`The database returned an invalid "${key}" value.`);
|
|
312
|
+
}
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
function readNullableStringProperty(row, key) {
|
|
316
|
+
const value = row[key];
|
|
317
|
+
if (value === null)
|
|
318
|
+
return null;
|
|
319
|
+
if (typeof value !== 'string') {
|
|
320
|
+
throw new Error(`The database returned an invalid "${key}" value.`);
|
|
321
|
+
}
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
function readNumberProperty(row, key) {
|
|
325
|
+
const value = asRow(row, 'database')[key];
|
|
326
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
327
|
+
throw new Error(`The database returned an invalid "${key}" value.`);
|
|
328
|
+
}
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
function readNullableNumberProperty(row, key) {
|
|
332
|
+
const value = row[key];
|
|
333
|
+
if (value === null)
|
|
334
|
+
return null;
|
|
335
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
336
|
+
throw new Error(`The database returned an invalid "${key}" value.`);
|
|
337
|
+
}
|
|
338
|
+
return value;
|
|
339
|
+
}
|
|
340
|
+
function readNullableTimestamp(row, key) {
|
|
341
|
+
const value = readNullableStringProperty(row, key);
|
|
342
|
+
return value === null ? null : normalizeTimestamp(value, key);
|
|
343
|
+
}
|
|
344
|
+
function normalizeTimestamp(value, field) {
|
|
345
|
+
const milliseconds = Date.parse(value);
|
|
346
|
+
if (!ISO_TIMESTAMP_PATTERN.test(value) || !Number.isFinite(milliseconds)) {
|
|
347
|
+
throw new TypeError(`${field} must be a valid ISO-8601 timestamp.`);
|
|
348
|
+
}
|
|
349
|
+
return new Date(milliseconds).toISOString();
|
|
350
|
+
}
|
|
351
|
+
function nullableTimestamp(value, field) {
|
|
352
|
+
return value === null ? null : normalizeTimestamp(value, field);
|
|
353
|
+
}
|
|
354
|
+
function serializeJson(value, field) {
|
|
355
|
+
if (!isJsonValue(value)) {
|
|
356
|
+
throw new TypeError(`${field} must contain JSON-compatible values.`);
|
|
357
|
+
}
|
|
358
|
+
const serialized = JSON.stringify(value);
|
|
359
|
+
if (serialized === undefined) {
|
|
360
|
+
throw new TypeError(`${field} could not be serialized as JSON.`);
|
|
361
|
+
}
|
|
362
|
+
return serialized;
|
|
363
|
+
}
|
|
364
|
+
function serializeJsonObject(value, field) {
|
|
365
|
+
if (!isJsonObject(value)) {
|
|
366
|
+
throw new TypeError(`${field} must be a JSON object.`);
|
|
367
|
+
}
|
|
368
|
+
return serializeJson(value, field);
|
|
369
|
+
}
|
|
370
|
+
function serializeHeaders(headers) {
|
|
371
|
+
if (!isStringRecord(headers)) {
|
|
372
|
+
throw new TypeError('headers must contain only string values.');
|
|
373
|
+
}
|
|
374
|
+
return JSON.stringify(headers);
|
|
375
|
+
}
|
|
376
|
+
function parseJson(value, field) {
|
|
377
|
+
let parsed;
|
|
378
|
+
try {
|
|
379
|
+
parsed = JSON.parse(value);
|
|
380
|
+
}
|
|
381
|
+
catch (cause) {
|
|
382
|
+
throw new TypeError(`The persisted ${field} value is invalid JSON.`, { cause });
|
|
383
|
+
}
|
|
384
|
+
if (!isJsonValue(parsed)) {
|
|
385
|
+
throw new TypeError(`The persisted ${field} value is not JSON-compatible.`);
|
|
386
|
+
}
|
|
387
|
+
return parsed;
|
|
388
|
+
}
|
|
389
|
+
function parseJsonObject(value, field) {
|
|
390
|
+
const parsed = parseJson(value, field);
|
|
391
|
+
if (!isJsonObject(parsed)) {
|
|
392
|
+
throw new TypeError(`The persisted ${field} value must be a JSON object.`);
|
|
393
|
+
}
|
|
394
|
+
return parsed;
|
|
395
|
+
}
|
|
396
|
+
function parseHeaders(value) {
|
|
397
|
+
let parsed;
|
|
398
|
+
try {
|
|
399
|
+
parsed = JSON.parse(value);
|
|
400
|
+
}
|
|
401
|
+
catch (cause) {
|
|
402
|
+
throw new TypeError('The persisted headers value is invalid JSON.', { cause });
|
|
403
|
+
}
|
|
404
|
+
if (!isStringRecord(parsed)) {
|
|
405
|
+
throw new TypeError('The persisted headers value must be a string object.');
|
|
406
|
+
}
|
|
407
|
+
return parsed;
|
|
408
|
+
}
|
|
409
|
+
function isJsonValue(value) {
|
|
410
|
+
return validateJsonValue(value, new WeakSet());
|
|
411
|
+
}
|
|
412
|
+
function validateJsonValue(value, ancestors) {
|
|
413
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
414
|
+
return true;
|
|
415
|
+
if (typeof value === 'number')
|
|
416
|
+
return Number.isFinite(value);
|
|
417
|
+
if (typeof value !== 'object' || value === null)
|
|
418
|
+
return false;
|
|
419
|
+
if (ancestors.has(value))
|
|
420
|
+
return false;
|
|
421
|
+
ancestors.add(value);
|
|
422
|
+
const valid = Array.isArray(value)
|
|
423
|
+
? value.every((entry) => validateJsonValue(entry, ancestors))
|
|
424
|
+
: isPlainJsonObject(value) &&
|
|
425
|
+
Object.values(value).every((entry) => validateJsonValue(entry, ancestors));
|
|
426
|
+
ancestors.delete(value);
|
|
427
|
+
return valid;
|
|
428
|
+
}
|
|
429
|
+
function isJsonObject(value) {
|
|
430
|
+
return (typeof value === 'object' &&
|
|
431
|
+
value !== null &&
|
|
432
|
+
!Array.isArray(value) &&
|
|
433
|
+
isJsonValue(value) &&
|
|
434
|
+
isPlainJsonObject(value));
|
|
435
|
+
}
|
|
436
|
+
function isPlainJsonObject(value) {
|
|
437
|
+
if (Array.isArray(value))
|
|
438
|
+
return false;
|
|
439
|
+
const prototype = Object.getPrototypeOf(value);
|
|
440
|
+
return prototype === Object.prototype || prototype === null;
|
|
441
|
+
}
|
|
442
|
+
function isStringRecord(value) {
|
|
443
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
444
|
+
return false;
|
|
445
|
+
const prototype = Object.getPrototypeOf(value);
|
|
446
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
447
|
+
return false;
|
|
448
|
+
return Object.values(value).every((entry) => typeof entry === 'string');
|
|
449
|
+
}
|
|
450
|
+
function assertPositiveSafeInteger(value, field) {
|
|
451
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
452
|
+
throw new RangeError(`${field} must be a positive safe integer.`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function encodeCursor(event) {
|
|
456
|
+
return Buffer.from(JSON.stringify([event.createdAt, event.id]), 'utf8').toString('base64url');
|
|
457
|
+
}
|
|
458
|
+
function decodeCursor(cursor) {
|
|
459
|
+
let decoded;
|
|
460
|
+
try {
|
|
461
|
+
decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
462
|
+
}
|
|
463
|
+
catch (cause) {
|
|
464
|
+
throw new InvalidPageCursorError({ cause });
|
|
465
|
+
}
|
|
466
|
+
if (!Array.isArray(decoded) ||
|
|
467
|
+
decoded.length !== 2 ||
|
|
468
|
+
typeof decoded[0] !== 'string' ||
|
|
469
|
+
typeof decoded[1] !== 'string') {
|
|
470
|
+
throw new InvalidPageCursorError();
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
return { createdAt: normalizeTimestamp(decoded[0], 'cursor'), id: decoded[1] };
|
|
474
|
+
}
|
|
475
|
+
catch (cause) {
|
|
476
|
+
throw new InvalidPageCursorError({ cause });
|
|
477
|
+
}
|
|
478
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webhook-delivery-engine",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Embeddable Node.js webhook delivery engine with persistent retries.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": "github:hamidjavadi/webhook-delivery-engine",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"webhook",
|
|
9
|
+
"webhook-delivery",
|
|
10
|
+
"webhook-retry",
|
|
11
|
+
"delivery",
|
|
12
|
+
"event-delivery",
|
|
13
|
+
"retry",
|
|
14
|
+
"idempotency",
|
|
15
|
+
"queue",
|
|
16
|
+
"sqlite",
|
|
17
|
+
"nodejs",
|
|
18
|
+
"typescript"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "./dist/src/index.js",
|
|
22
|
+
"types": "./dist/src/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/src/index.d.ts",
|
|
26
|
+
"import": "./dist/src/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist/src"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc --project tsconfig.json && node --input-type=module -e \"import { cpSync } from 'node:fs'; cpSync('src/storage/migrations/sql', 'dist/src/storage/migrations/sql', { recursive: true });\"",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"benchmark:ingestion": "tsx scripts/benchmark-ingestion.ts",
|
|
36
|
+
"test:watch": "vitest",
|
|
37
|
+
"lint": "eslint . --max-warnings 0",
|
|
38
|
+
"format": "prettier --write ."
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"better-sqlite3": "^11.8.1",
|
|
42
|
+
"dotenv": "^16.4.7",
|
|
43
|
+
"pino": "^9.6.0",
|
|
44
|
+
"undici": "8.12.3",
|
|
45
|
+
"zod": "^3.24.2"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@eslint/js": "^9.20.0",
|
|
49
|
+
"@types/better-sqlite3": "^7.6.12",
|
|
50
|
+
"@types/node": "^24.0.0",
|
|
51
|
+
"@vitest/coverage-v8": "^3.2.7",
|
|
52
|
+
"eslint": "^9.20.1",
|
|
53
|
+
"node-gyp": "^13.1.0",
|
|
54
|
+
"prettier": "^3.5.1",
|
|
55
|
+
"tsx": "^4.19.3",
|
|
56
|
+
"typescript": "^5.7.3",
|
|
57
|
+
"typescript-eslint": "^8.24.0",
|
|
58
|
+
"vitest": "^3.0.5"
|
|
59
|
+
},
|
|
60
|
+
"lint-staged": {
|
|
61
|
+
"*.ts": [
|
|
62
|
+
"eslint --fix",
|
|
63
|
+
"prettier --write"
|
|
64
|
+
],
|
|
65
|
+
"*.{json,md}": [
|
|
66
|
+
"prettier --write"
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
"packageManager": "pnpm@10.4.1",
|
|
70
|
+
"pnpm": {
|
|
71
|
+
"onlyBuiltDependencies": [
|
|
72
|
+
"better-sqlite3"
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"engines": {
|
|
76
|
+
"node": ">=24.0.0"
|
|
77
|
+
}
|
|
78
|
+
}
|