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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hamid Javadi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,112 @@
1
- # Temporary Holding Version
1
+ # Webhook Delivery Engine
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A lightweight, embeddable Node.js and TypeScript library for persistent, idempotent webhook delivery with bounded retries.
4
+
5
+ **Requirements:** Node.js 24 or newer.
6
+ **Database:** SQLite only in v1.0.0. PostgreSQL and MySQL support is planned for a future release.
7
+
8
+ ---
9
+
10
+ ## Key Goals
11
+
12
+ - **Idempotent Ingestion:** Safely accept events without creating duplicate delivery jobs.
13
+ - **Persistent queue:** Events and attempt history are stored in SQLite.
14
+ - **Bounded retries:** Retryable delivery failures use capped exponential backoff with full jitter.
15
+ - **Attempt history:** Query event status and recorded delivery attempts from the host application.
16
+ - **Signed requests:** Outbound requests include HMAC-SHA256 signature, timestamp, and event ID headers.
17
+
18
+ Version 1.0.0 embeds the queue and its workers in your Node.js process and uses SQLite. The project plans to grow toward separately configured clients, separate worker processes, PostgreSQL and MySQL backends, and stronger delivery guarantees, including exactly-once delivery as a future goal. These capabilities are not part of v1.0.0.
19
+
20
+ ---
21
+
22
+ ## Prerequisites
23
+
24
+ - **Node.js:** `>= 24`
25
+ - **Storage:** A writable, persistent local filesystem with SQLite locking support.
26
+ - **Secret:** A non-empty HMAC signing secret supplied by the host at runtime.
27
+
28
+ ---
29
+
30
+ ## Install and Embed
31
+
32
+ Install the package in a Node.js 24 or newer application:
33
+
34
+ ```bash
35
+ npm install webhook-delivery-engine
36
+ ```
37
+
38
+ The SQLite driver uses the native `better-sqlite3` module. Your package manager may require permission to run its install script. Create the database directory before starting the engine:
39
+
40
+ ```bash
41
+ mkdir -p ./data
42
+ ```
43
+
44
+ Create and start an engine instance in the host application:
45
+
46
+ ```ts
47
+ import { createWebhookEngine } from 'webhook-delivery-engine';
48
+
49
+ const hmacSecret = process.env.WEBHOOK_HMAC_SECRET;
50
+ if (!hmacSecret) throw new Error('WEBHOOK_HMAC_SECRET is required');
51
+
52
+ const engine = createWebhookEngine({
53
+ databasePath: './data/webhook-delivery-engine.db',
54
+ clientId: 'billing-service',
55
+ hmacSecret,
56
+ defaultDestinationUrl: 'https://consumer.example/hooks',
57
+ config: {
58
+ concurrency: { workerCount: 4, batchSize: 10, pollIntervalMs: 1_000 },
59
+ timeoutLimitMs: 10_000,
60
+ retry: { maxAttempts: 5, baseDelayMs: 1_000, maxDelayMs: 60_000 },
61
+ },
62
+ });
63
+
64
+ await engine.start();
65
+ try {
66
+ const result = await engine.enqueue({
67
+ idempotency_key: 'invoice-123',
68
+ payload: { event: 'invoice.paid', invoiceId: 'invoice-123' },
69
+ });
70
+ if (result.statusCode !== 202) throw new Error(`Event rejected: ${result.body.error}`);
71
+
72
+ const event = await engine.getEvent(result.body.id);
73
+ console.log(event?.status);
74
+ } finally {
75
+ await engine.stop();
76
+ }
77
+ ```
78
+
79
+ `start()` applies bundled SQLite migrations and starts polling. Call engine methods only after it resolves. `stop()` stops polling, releases claims that have not started dispatch, waits for active deliveries, and closes the database. The host owns process signals and must await `stop()` during shutdown.
80
+
81
+ The configured `clientId` is bound to the engine and stamped on accepted events. The library is an in-process API: it does not provide an HTTP server or authenticate callers. If you expose an external endpoint, authenticate and authorize requests in the host before calling the engine. Use a separate SQLite database for each differently configured client; v1 does not support sharing one database between clients with different secrets.
82
+
83
+ ## Delivery behavior and security
84
+
85
+ - Delivery is **at least once**, not exactly once. Consumers should deduplicate using the stable `x-webhook-event-id` header.
86
+ - A `2xx` response is successful. Retryable client responses (`408`, `409`, `429`), server errors, timeouts, and network errors are retried up to `maxAttempts`; other `4xx` responses fail immediately. Events finish as `delivered` or `failed`, with intermediate `pending`, `processing`, or `retry` states.
87
+ - Each request is signed with HMAC-SHA256 over `<timestamp>.<raw JSON body>`. The engine sends `x-webhook-signature` (`sha256=<hex>`), `x-webhook-timestamp` (Unix seconds), and `x-webhook-event-id`. The consumer must verify the signature and enforce its own timestamp freshness window.
88
+ - Destinations must use HTTPS and pass the engine's SSRF checks. The dispatcher pins validated addresses and does not follow redirects.
89
+ - SQLite persistence survives process restarts when the database is on persistent storage. Back up the database using a SQLite-consistent backup method; do not rely on ephemeral filesystems for durable data.
90
+ - Structured JSON logs are written to standard output. `logLevel` or `LOG_LEVEL` controls verbosity; sensitive payload, credential, and request fields are redacted.
91
+
92
+ See the [Embedded API Guide](docs/API.md) for option details, ingestion results,
93
+ tenant-scoped queries, security expectations, and development commands.
94
+
95
+ ## Interactive Demo
96
+
97
+ The [hosted package demo](examples/demo/README.md) shows successful delivery, retry recovery, terminal failure, idempotency, and attempt history using the public package interface. Its Render deployment is separate from the library release and is not automatically deployed by this repository.
98
+
99
+ ## Development
100
+
101
+ Maintainers use pnpm 10.4.1 with the committed lockfile:
102
+
103
+ | Script | Description |
104
+ | --------------- | --------------------------------------------------- |
105
+ | pnpm build | Builds ESM output, declarations, and migration assets |
106
+ | pnpm test | Executes unit and integration test suites |
107
+ | pnpm lint | Lints codebase |
108
+ | pnpm test:watch | Runs Vitest in interactive watch mode |
109
+
110
+ ## License
111
+
112
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ import { type MigrationRunnerOptions } from '../storage/migrations/runner.js';
2
+ /** Options for ensuring database migrations finish before application startup. */
3
+ export type ApplicationBootstrapOptions = MigrationRunnerOptions & {
4
+ /** Starts the scheduler, server, or embedded application after migrations complete. */
5
+ readonly start: () => void | Promise<void>;
6
+ };
7
+ /**
8
+ * Applies pending database migrations before starting application components.
9
+ *
10
+ * @param options - Migration connection, dialect, optional SQL directory, and start callback.
11
+ * @returns A promise that resolves after migrations and application startup succeed.
12
+ * @throws {@link MigrationError} when migration application fails; the start callback is not run.
13
+ */
14
+ export declare function bootstrapApplication(options: ApplicationBootstrapOptions): Promise<void>;
@@ -0,0 +1,13 @@
1
+ import { runMigrations } from '../storage/migrations/runner.js';
2
+ /**
3
+ * Applies pending database migrations before starting application components.
4
+ *
5
+ * @param options - Migration connection, dialect, optional SQL directory, and start callback.
6
+ * @returns A promise that resolves after migrations and application startup succeed.
7
+ * @throws {@link MigrationError} when migration application fails; the start callback is not run.
8
+ */
9
+ export async function bootstrapApplication(options) {
10
+ const { start, ...migrationOptions } = options;
11
+ await runMigrations(migrationOptions);
12
+ await start();
13
+ }
@@ -0,0 +1,45 @@
1
+ import type { EngineConfig } from '../config/engine.config.js';
2
+ import type { EventPage, ListEventsInput } from '../core/ports.js';
3
+ import type { AttemptRecord, WebhookEvent } from '../core/types.js';
4
+ import type { IngestionRequest } from '../features/ingestion/ingestion.schema.js';
5
+ import { handleIngestion } from '../features/ingestion/ingestion.handler.js';
6
+ import { type LogLevel } from '../shared/logger.js';
7
+ /** Options required to create an embedded webhook engine. */
8
+ export interface CreateWebhookEngineOptions {
9
+ /** Persistent SQLite database filename. */
10
+ readonly databasePath: string;
11
+ /** Tenant identity fixed for this engine instance. */
12
+ readonly clientId: string;
13
+ /** HMAC key used to sign outbound requests. */
14
+ readonly hmacSecret: string;
15
+ /** HTTPS destination used when an ingestion request omits destination_url. */
16
+ readonly defaultDestinationUrl: string;
17
+ /** Required polling, timeout, and retry controls. */
18
+ readonly config: EngineConfig;
19
+ /** Log threshold; defaults to LOG_LEVEL or info. */
20
+ readonly logLevel?: LogLevel;
21
+ /** Receives original scheduler and delivery failures; poll errors omit the event. */
22
+ readonly onError?: (error: unknown, event?: WebhookEvent) => void;
23
+ }
24
+ /** Tenant-scoped programmatic event query. */
25
+ export type EngineListEventsInput = Omit<ListEventsInput, 'clientId'>;
26
+ /** Embedded engine lifecycle and programmatic API. */
27
+ export interface WebhookEngine {
28
+ /** Applies bundled migrations and starts polling. Safe to call repeatedly. */
29
+ start(): Promise<void>;
30
+ /** Stops polling, drains started dispatches, releases queued claims, and closes SQLite. */
31
+ stop(): Promise<void>;
32
+ /** Validates and enqueues an event for the configured tenant. */
33
+ enqueue(input: IngestionRequest): ReturnType<typeof handleIngestion>;
34
+ /** Returns an event by system id or idempotency key within the configured tenant. */
35
+ getEvent(identifier: string): Promise<WebhookEvent | null>;
36
+ /** Returns a tenant-scoped event page. */
37
+ listEvents(input: EngineListEventsInput): Promise<EventPage>;
38
+ /** Returns attempt history for an event owned by the configured tenant. */
39
+ listAttempts(eventId: string): Promise<readonly AttemptRecord[]>;
40
+ }
41
+ /**
42
+ * Creates the public embedded composition root. It does not bind a listener or
43
+ * register process signals; the host owns both responsibilities.
44
+ */
45
+ export declare function createWebhookEngine(options: CreateWebhookEngineOptions): WebhookEngine;
@@ -0,0 +1,228 @@
1
+ import { createDeliveryWorker } from '../features/delivery/delivery.worker.js';
2
+ import { handleIngestion } from '../features/ingestion/ingestion.handler.js';
3
+ import { ClaimScheduler } from '../features/scheduler/scheduler.js';
4
+ import { createStructuredLogger, resolveLogLevel } from '../shared/logger.js';
5
+ import { SqliteDatabaseAdapter } from '../storage/sqlite.adapter.js';
6
+ import { GracefulShutdown } from './lifecycle.js';
7
+ /**
8
+ * Creates the public embedded composition root. It does not bind a listener or
9
+ * register process signals; the host owns both responsibilities.
10
+ */
11
+ export function createWebhookEngine(options) {
12
+ validateOptions(options);
13
+ const logger = createStructuredLogger({
14
+ level: resolveLogLevel(options.logLevel, process.env.LOG_LEVEL),
15
+ });
16
+ let adapter = null;
17
+ let scheduler = null;
18
+ let lifecycle = null;
19
+ let deliveryWorker = null;
20
+ let startPromise = null;
21
+ let stopPromise = null;
22
+ let acceptingDispatches = false;
23
+ let activeWorkers = 0;
24
+ const queuedClaims = [];
25
+ let state = 'new';
26
+ const requireRunning = () => {
27
+ if (state !== 'running' || adapter === null) {
28
+ throw new Error('Webhook engine must be started and not stopping before use.');
29
+ }
30
+ return adapter;
31
+ };
32
+ return {
33
+ async start() {
34
+ if (state === 'running')
35
+ return;
36
+ if (state === 'starting')
37
+ return startPromise;
38
+ if (state !== 'new')
39
+ throw new Error(`Webhook engine cannot start from state ${state}.`);
40
+ state = 'starting';
41
+ startPromise = (async () => {
42
+ try {
43
+ const db = await SqliteDatabaseAdapter.open(options.databasePath);
44
+ adapter = db;
45
+ deliveryWorker = createDeliveryWorker({
46
+ adapter: db,
47
+ hmacSecret: options.hmacSecret,
48
+ timeoutLimitMs: options.config.timeoutLimitMs,
49
+ retry: options.config.retry,
50
+ onError: (error, event) => reportError(error, event),
51
+ onDispatchStarted: (event, work) => {
52
+ logger.info({
53
+ event: 'delivery.dispatch.started',
54
+ eventId: event.id,
55
+ clientId: event.clientId,
56
+ attemptNumber: event.attemptCount,
57
+ }, 'delivery dispatch started');
58
+ lifecycle?.trackDispatch(event.id, work);
59
+ const finish = () => {
60
+ activeWorkers -= 1;
61
+ startQueuedClaims();
62
+ };
63
+ void work.then(finish, finish);
64
+ },
65
+ onAttemptCompleted: (event, result) => {
66
+ logger.info({
67
+ event: 'delivery.attempt.completed',
68
+ eventId: event.id,
69
+ clientId: event.clientId,
70
+ attemptNumber: event.attemptCount,
71
+ outcome: result.outcome,
72
+ nextStatus: result.nextStatus,
73
+ statusCode: result.statusCode,
74
+ latencyMs: result.latencyMs,
75
+ persisted: result.persisted,
76
+ }, 'delivery attempt completed');
77
+ },
78
+ });
79
+ scheduler = new ClaimScheduler(db, options.config, (event) => {
80
+ lifecycle?.trackClaimed(event);
81
+ logger.info({
82
+ event: 'delivery.claimed',
83
+ eventId: event.id,
84
+ clientId: event.clientId,
85
+ attemptNumber: event.attemptCount,
86
+ }, 'delivery event claimed');
87
+ if (!acceptingDispatches)
88
+ return;
89
+ queuedClaims.push(event);
90
+ startQueuedClaims();
91
+ }, (error) => reportError(error), (recoveredCount) => {
92
+ if (recoveredCount === 0)
93
+ return;
94
+ logger.warn({ event: 'scheduler.stale_leases_recovered', recoveredCount }, 'stale delivery leases recovered');
95
+ });
96
+ lifecycle = new GracefulShutdown(scheduler, db);
97
+ acceptingDispatches = true;
98
+ scheduler.start();
99
+ state = 'running';
100
+ logger.info({ event: 'engine.started', clientId: options.clientId }, 'engine started');
101
+ }
102
+ catch (error) {
103
+ logger.error({ event: 'engine.start.failed', errorType: errorType(error) }, 'engine startup failed');
104
+ adapter?.close();
105
+ adapter = null;
106
+ state = 'failed';
107
+ throw error;
108
+ }
109
+ })();
110
+ return startPromise;
111
+ },
112
+ async stop() {
113
+ if (state === 'stopped')
114
+ return;
115
+ if (state === 'stopping')
116
+ return stopPromise;
117
+ if (state === 'new' || state === 'failed') {
118
+ state = 'stopped';
119
+ return;
120
+ }
121
+ if (state === 'starting')
122
+ await startPromise;
123
+ state = 'stopping';
124
+ acceptingDispatches = false;
125
+ logger.info({ event: 'engine.stopping', clientId: options.clientId }, 'engine stopping');
126
+ stopPromise = (async () => {
127
+ try {
128
+ await lifecycle?.shutdown();
129
+ }
130
+ finally {
131
+ adapter?.close();
132
+ adapter = null;
133
+ state = 'stopped';
134
+ logger.info({ event: 'engine.stopped', clientId: options.clientId }, 'engine stopped');
135
+ }
136
+ })();
137
+ return stopPromise;
138
+ },
139
+ async enqueue(input) {
140
+ const db = requireRunning();
141
+ const result = await handleIngestion({
142
+ clientId: options.clientId,
143
+ body: input,
144
+ defaultDestinationUrl: options.defaultDestinationUrl,
145
+ database: db,
146
+ });
147
+ if (result.statusCode === 202) {
148
+ logger.info({
149
+ event: 'ingestion.accepted',
150
+ eventId: result.body.id,
151
+ clientId: options.clientId,
152
+ idempotentReplay: result.body.idempotent_replay,
153
+ }, 'webhook event accepted');
154
+ }
155
+ return result;
156
+ },
157
+ async getEvent(identifier) {
158
+ const db = requireRunning();
159
+ return ((await db.findEventById(identifier, options.clientId)) ??
160
+ db.findEventByIdempotencyKey(options.clientId, identifier));
161
+ },
162
+ async listEvents(input) {
163
+ const db = requireRunning();
164
+ return db.listEvents({ ...input, clientId: options.clientId });
165
+ },
166
+ async listAttempts(eventId) {
167
+ const db = requireRunning();
168
+ const event = await db.findEventById(eventId, options.clientId);
169
+ return event === null ? [] : db.listAttempts(event.id, options.clientId);
170
+ },
171
+ };
172
+ function startQueuedClaims() {
173
+ while (acceptingDispatches &&
174
+ activeWorkers < options.config.concurrency.workerCount &&
175
+ queuedClaims.length > 0) {
176
+ const event = queuedClaims.shift();
177
+ activeWorkers += 1;
178
+ deliveryWorker?.(event);
179
+ }
180
+ }
181
+ function reportError(error, event) {
182
+ logger.error({
183
+ event: event === undefined ? 'scheduler.poll.failed' : 'delivery.processing.failed',
184
+ ...(event === undefined ? {} : { eventId: event.id, clientId: event.clientId }),
185
+ errorType: errorType(error),
186
+ }, event === undefined ? 'scheduler poll failed' : 'delivery processing failed');
187
+ if (options.onError !== undefined) {
188
+ options.onError(error, event);
189
+ }
190
+ }
191
+ }
192
+ function errorType(error) {
193
+ if (error instanceof TypeError)
194
+ return 'TypeError';
195
+ if (error instanceof RangeError)
196
+ return 'RangeError';
197
+ if (error instanceof SyntaxError)
198
+ return 'SyntaxError';
199
+ return error instanceof Error ? 'Error' : typeof error;
200
+ }
201
+ function validateOptions(options) {
202
+ for (const [name, value] of Object.entries({
203
+ databasePath: options.databasePath,
204
+ clientId: options.clientId,
205
+ hmacSecret: options.hmacSecret,
206
+ defaultDestinationUrl: options.defaultDestinationUrl,
207
+ })) {
208
+ if (typeof value !== 'string' || value.trim().length === 0) {
209
+ throw new TypeError(`${name} must be a non-empty string.`);
210
+ }
211
+ }
212
+ const { concurrency, timeoutLimitMs, retry } = options.config;
213
+ const positive = [
214
+ concurrency.workerCount,
215
+ concurrency.batchSize,
216
+ concurrency.pollIntervalMs,
217
+ timeoutLimitMs,
218
+ retry.maxAttempts,
219
+ retry.baseDelayMs,
220
+ retry.maxDelayMs,
221
+ ];
222
+ if (!positive.every((value) => Number.isSafeInteger(value) && value > 0)) {
223
+ throw new RangeError('Engine configuration values must be positive safe integers.');
224
+ }
225
+ if (retry.maxDelayMs < retry.baseDelayMs) {
226
+ throw new RangeError('retry.maxDelayMs must be greater than or equal to retry.baseDelayMs.');
227
+ }
228
+ }
@@ -0,0 +1,10 @@
1
+ import { type Server } from 'node:http';
2
+ import type { DatabaseAdapter } from '../core/ports.js';
3
+ import type { ApiKeyAuthenticator } from '../security/auth.js';
4
+ export interface WebhookHttpServerOptions {
5
+ readonly database: DatabaseAdapter;
6
+ readonly authenticate: ApiKeyAuthenticator;
7
+ readonly defaultDestinationUrl: string;
8
+ }
9
+ /** Creates the standalone REST server without starting a listener. */
10
+ export declare function createWebhookHttpServer(options: WebhookHttpServerOptions): Server;
@@ -0,0 +1,65 @@
1
+ import { createServer } from 'node:http';
2
+ import { handleIngestionRoute } from '../features/ingestion/ingestion.routes.js';
3
+ import { handleQueryRoute } from '../features/query/query.routes.js';
4
+ import { sendJson } from '../features/http/http.responses.js';
5
+ /** Creates the standalone REST server without starting a listener. */
6
+ export function createWebhookHttpServer(options) {
7
+ return createServer((request, response) => {
8
+ void routeRequest(request, response, options).catch(() => {
9
+ if (!response.headersSent) {
10
+ sendJson(response, 503, { error: 'service_unavailable' });
11
+ }
12
+ else {
13
+ response.destroy();
14
+ }
15
+ });
16
+ });
17
+ }
18
+ async function routeRequest(request, response, options) {
19
+ let url;
20
+ try {
21
+ url = new URL(request.url, 'http://localhost');
22
+ }
23
+ catch {
24
+ sendJson(response, 400, { error: 'invalid_request' });
25
+ return;
26
+ }
27
+ if (url.pathname !== '/webhooks' && !url.pathname.startsWith('/webhooks/')) {
28
+ sendJson(response, 404, { error: 'not_found' });
29
+ return;
30
+ }
31
+ if (request.method !== 'GET' && !(url.pathname === '/webhooks' && request.method === 'POST')) {
32
+ sendJson(response, 405, { error: 'method_not_allowed' });
33
+ return;
34
+ }
35
+ const authentication = options.authenticate(extractApiKey(request));
36
+ if (!authentication.authenticated) {
37
+ sendJson(response, 401, { error: 'unauthorized' });
38
+ return;
39
+ }
40
+ if (url.pathname === '/webhooks' && request.method === 'POST') {
41
+ await handleIngestionRoute({
42
+ request,
43
+ response,
44
+ authentication,
45
+ database: options.database,
46
+ defaultDestinationUrl: options.defaultDestinationUrl,
47
+ });
48
+ return;
49
+ }
50
+ await handleQueryRoute({
51
+ response,
52
+ url,
53
+ authentication,
54
+ database: options.database,
55
+ });
56
+ }
57
+ function extractApiKey(request) {
58
+ const authorization = request.headers.authorization;
59
+ if (authorization !== undefined) {
60
+ const match = /^Bearer ([^\s]+)$/i.exec(authorization);
61
+ return match?.[1];
62
+ }
63
+ const apiKey = request.headers['x-api-key'];
64
+ return typeof apiKey === 'string' ? apiKey : undefined;
65
+ }
@@ -0,0 +1,108 @@
1
+ import type { WebhookEvent } from '../core/types.js';
2
+ import type { DatabaseAdapter } from '../core/ports.js';
3
+ /**
4
+ * Minimal scheduler interface required by the graceful shutdown coordinator.
5
+ *
6
+ * This keeps the lifecycle module decoupled from the concrete `ClaimScheduler`
7
+ * implementation while still being able to stop the poll loop.
8
+ */
9
+ export interface StoppableScheduler {
10
+ /** Stops the poll loop and resolves when the current cycle completes. */
11
+ stop(): Promise<void>;
12
+ }
13
+ /**
14
+ * Coordinates a clean process exit on SIGINT or SIGTERM.
15
+ *
16
+ * Responsibilities (WBS 4.2):
17
+ * 1. Register one-time SIGINT and SIGTERM handlers via `register()`.
18
+ * 2. On signal (or direct `shutdown()` call): stop the scheduler so no new
19
+ * events are claimed.
20
+ * 3. Call `DatabaseAdapter.releaseEventLease` for every event that was claimed
21
+ * but whose dispatch has not yet started, returning those leases to the
22
+ * `pending` state so they are picked up on the next startup.
23
+ * 4. Remain idempotent — duplicate signals or `shutdown()` calls run the
24
+ * shutdown sequence exactly once.
25
+ *
26
+ * The lifecycle module does NOT implement the poll loop itself (WBS 4.1), does
27
+ * NOT dispatch events, and does NOT know about HMAC signing or HTTP transport.
28
+ *
29
+ * ### Lease tracking contract
30
+ *
31
+ * When the scheduler claims an event and hands it to the worker seam, callers
32
+ * should immediately call `trackClaimed(event)`. When network dispatch starts,
33
+ * call `trackDispatch(event.id, work)` to move the event from releasable claims
34
+ * to work that shutdown must drain. `untrackClaimed` remains available for
35
+ * callers that only need claim release tracking.
36
+ */
37
+ export declare class GracefulShutdown {
38
+ private readonly scheduler;
39
+ private readonly adapter;
40
+ /**
41
+ * Events that have been claimed by the scheduler but have not yet started
42
+ * network dispatch. Keyed by event id for O(1) lookup and removal.
43
+ */
44
+ private readonly pendingClaims;
45
+ /** Dispatches that crossed the network-start boundary and must be drained. */
46
+ private readonly activeDispatches;
47
+ /** Whether the shutdown sequence has been started (guards idempotency). */
48
+ private shuttingDown;
49
+ /** Shared completion promise so concurrent stop calls await the same drain. */
50
+ private shutdownPromise;
51
+ /** Whether `register()` has been called (guards duplicate listener registration). */
52
+ private registered;
53
+ /**
54
+ * Creates a lifecycle coordinator for the given scheduler and adapter.
55
+ *
56
+ * @param scheduler - The running poll loop to stop on shutdown.
57
+ * @param adapter - Adapter used to release unclaimed-dispatch leases.
58
+ */
59
+ constructor(scheduler: StoppableScheduler, adapter: Pick<DatabaseAdapter, 'releaseEventLease'>);
60
+ /**
61
+ * Registers one-time SIGINT and SIGTERM listeners on `process`.
62
+ *
63
+ * Safe to call multiple times — only one pair of listeners is ever added.
64
+ * Each signal triggers `shutdown()` which is itself idempotent.
65
+ */
66
+ register(): void;
67
+ /**
68
+ * Marks an event as claimed-but-not-dispatched.
69
+ *
70
+ * Call this immediately after the scheduler hands an event to the worker
71
+ * seam so that a concurrent or subsequent `shutdown()` can release its
72
+ * lease.
73
+ *
74
+ * @param event - The claimed event to track.
75
+ */
76
+ trackClaimed(event: WebhookEvent): void;
77
+ /**
78
+ * Removes an event from the pending-claim tracking set.
79
+ *
80
+ * Call this when the worker begins actual network dispatch for an event.
81
+ * After removal, `shutdown()` will not attempt to release that lease —
82
+ * the stale-lease recovery mechanism will handle it if the process aborts
83
+ * mid-dispatch.
84
+ *
85
+ * @param eventId - Id of the event whose dispatch has started.
86
+ */
87
+ untrackClaimed(eventId: string): void;
88
+ /**
89
+ * Marks dispatch as started and tracks its completion for shutdown.
90
+ * The lease is no longer eligible for release once network dispatch begins.
91
+ */
92
+ trackDispatch(eventId: string, dispatch: Promise<unknown>): void;
93
+ /**
94
+ * Executes the graceful shutdown sequence exactly once.
95
+ *
96
+ * 1. Stops the scheduler (no new claims after this point).
97
+ * 2. Releases the leases of all still-tracked (not-yet-dispatched) events
98
+ * by calling `releaseEventLease` for each. Failures for individual events
99
+ * are swallowed so that the remaining releases still run; those events
100
+ * will be recovered by the stale-lease mechanism on next startup.
101
+ *
102
+ * Safe to call multiple times — subsequent calls resolve immediately.
103
+ *
104
+ * @returns A promise that resolves when the shutdown sequence completes.
105
+ */
106
+ shutdown(): Promise<void>;
107
+ private drainAndRelease;
108
+ }