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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/dist/src/app/bootstrap.d.ts +14 -0
  4. package/dist/src/app/bootstrap.js +13 -0
  5. package/dist/src/app/embedded.d.ts +45 -0
  6. package/dist/src/app/embedded.js +228 -0
  7. package/dist/src/app/http.server.d.ts +10 -0
  8. package/dist/src/app/http.server.js +65 -0
  9. package/dist/src/app/lifecycle.d.ts +108 -0
  10. package/dist/src/app/lifecycle.js +138 -0
  11. package/dist/src/config/engine.config.d.ts +45 -0
  12. package/dist/src/config/engine.config.js +1 -0
  13. package/dist/src/core/errors.d.ts +4 -0
  14. package/dist/src/core/errors.js +7 -0
  15. package/dist/src/core/ports.d.ts +217 -0
  16. package/dist/src/core/ports.js +1 -0
  17. package/dist/src/core/types.d.ts +98 -0
  18. package/dist/src/core/types.js +10 -0
  19. package/dist/src/features/delivery/delivery.backoff.d.ts +74 -0
  20. package/dist/src/features/delivery/delivery.backoff.js +56 -0
  21. package/dist/src/features/delivery/delivery.classify.d.ts +28 -0
  22. package/dist/src/features/delivery/delivery.classify.js +38 -0
  23. package/dist/src/features/delivery/delivery.dispatcher.d.ts +57 -0
  24. package/dist/src/features/delivery/delivery.dispatcher.js +155 -0
  25. package/dist/src/features/delivery/delivery.hmac.d.ts +62 -0
  26. package/dist/src/features/delivery/delivery.hmac.js +31 -0
  27. package/dist/src/features/delivery/delivery.worker.d.ts +60 -0
  28. package/dist/src/features/delivery/delivery.worker.js +136 -0
  29. package/dist/src/features/http/http.responses.d.ts +6 -0
  30. package/dist/src/features/http/http.responses.js +45 -0
  31. package/dist/src/features/ingestion/ingestion.handler.d.ts +81 -0
  32. package/dist/src/features/ingestion/ingestion.handler.js +59 -0
  33. package/dist/src/features/ingestion/ingestion.routes.d.ts +12 -0
  34. package/dist/src/features/ingestion/ingestion.routes.js +25 -0
  35. package/dist/src/features/ingestion/ingestion.schema.d.ts +26 -0
  36. package/dist/src/features/ingestion/ingestion.schema.js +24 -0
  37. package/dist/src/features/query/query.routes.d.ts +11 -0
  38. package/dist/src/features/query/query.routes.js +99 -0
  39. package/dist/src/features/scheduler/scheduler.d.ts +110 -0
  40. package/dist/src/features/scheduler/scheduler.js +153 -0
  41. package/dist/src/index.d.ts +8 -0
  42. package/dist/src/index.js +1 -0
  43. package/dist/src/security/auth.d.ts +40 -0
  44. package/dist/src/security/auth.js +39 -0
  45. package/dist/src/security/ssrf.d.ts +47 -0
  46. package/dist/src/security/ssrf.js +173 -0
  47. package/dist/src/shared/logger.d.ts +11 -0
  48. package/dist/src/shared/logger.js +52 -0
  49. package/dist/src/storage/migrations/runner.d.ts +51 -0
  50. package/dist/src/storage/migrations/runner.js +155 -0
  51. package/dist/src/storage/migrations/sql/sqlite/001_initial_schema.sql +52 -0
  52. package/dist/src/storage/sqlite.adapter.d.ts +51 -0
  53. package/dist/src/storage/sqlite.adapter.js +478 -0
  54. 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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }