@azizikri/hookpipe 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 +325 -0
- package/package.json +64 -0
- package/src/auth/hmac.js +100 -0
- package/src/cli/index.js +31 -0
- package/src/cli/logs.js +86 -0
- package/src/cli/replay.js +64 -0
- package/src/cli/serve.js +75 -0
- package/src/cli/test.js +135 -0
- package/src/db/index.js +60 -0
- package/src/db/migrations/001_initial.sql +107 -0
- package/src/db/queries.js +196 -0
- package/src/delivery/retry.js +63 -0
- package/src/delivery/worker.js +192 -0
- package/src/destinations/http.js +60 -0
- package/src/destinations/index.js +30 -0
- package/src/destinations/interface.js +47 -0
- package/src/pipeline-loader.js +151 -0
- package/src/plugin-loader.js +88 -0
- package/src/queue/interface.js +68 -0
- package/src/queue/sqlite-queue.js +83 -0
- package/src/server.js +124 -0
- package/src/templates/handlebars.js +21 -0
- package/src/utils/config.js +115 -0
- package/src/utils/crypto.js +44 -0
- package/src/utils/logger.js +35 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { ulid } from 'ulid';
|
|
2
|
+
import { calculateNextDelay, shouldRetry, getNextAttemptTime } from './retry.js';
|
|
3
|
+
import { insertAttempt, updateDeliveryStatus, incrementStat } from '../db/queries.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONFIG = {
|
|
6
|
+
pollIntervalMs: 1000,
|
|
7
|
+
concurrency: 5,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const DEFAULT_RETRY_CONFIG = {
|
|
11
|
+
maxAttempts: 3,
|
|
12
|
+
backoff: 'exponential',
|
|
13
|
+
initialDelayMs: 1000,
|
|
14
|
+
maxDelayMs: 300000,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class DeliveryWorker {
|
|
18
|
+
#queue;
|
|
19
|
+
#db;
|
|
20
|
+
#destinationRegistry;
|
|
21
|
+
#logger;
|
|
22
|
+
#config;
|
|
23
|
+
#getPipeline;
|
|
24
|
+
#interval = null;
|
|
25
|
+
#isPolling = false;
|
|
26
|
+
|
|
27
|
+
constructor({ queue, db, destinationRegistry, logger, config = {}, getPipeline }) {
|
|
28
|
+
if (typeof getPipeline !== 'function') {
|
|
29
|
+
throw new Error('DeliveryWorker requires getPipeline(pipelineId)');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.#queue = queue;
|
|
33
|
+
this.#db = db;
|
|
34
|
+
this.#destinationRegistry = destinationRegistry;
|
|
35
|
+
this.#logger = logger;
|
|
36
|
+
this.#config = { ...DEFAULT_CONFIG, ...config };
|
|
37
|
+
this.#getPipeline = getPipeline;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
start() {
|
|
41
|
+
if (this.#interval) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.#interval = setInterval(() => {
|
|
46
|
+
this.#poll().catch((error) => {
|
|
47
|
+
this.#logger?.error?.({ error }, 'delivery worker poll failed');
|
|
48
|
+
});
|
|
49
|
+
}, this.#config.pollIntervalMs);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
stop() {
|
|
53
|
+
if (!this.#interval) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
clearInterval(this.#interval);
|
|
58
|
+
this.#interval = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async #poll() {
|
|
62
|
+
if (this.#isPolling) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.#isPolling = true;
|
|
67
|
+
try {
|
|
68
|
+
const jobs = await this.#queue.dequeue(this.#config.concurrency);
|
|
69
|
+
await Promise.all(jobs.map((job) => this.#processJob(job)));
|
|
70
|
+
} finally {
|
|
71
|
+
this.#isPolling = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async #processJob(job) {
|
|
76
|
+
const startedAt = Date.now();
|
|
77
|
+
const pipeline = await this.#getPipeline(job.pipeline_id);
|
|
78
|
+
const destConfig = getDestinationConfig(pipeline, job.destination_id);
|
|
79
|
+
const retryConfig = getRetryConfig(pipeline);
|
|
80
|
+
const adapter = this.#destinationRegistry.getAdapter(destConfig.type);
|
|
81
|
+
const payload = typeof job.payload === 'string' ? JSON.parse(job.payload) : job.payload;
|
|
82
|
+
const headers = typeof job.headers === 'string' ? JSON.parse(job.headers) : job.headers;
|
|
83
|
+
const context = {
|
|
84
|
+
deliveryId: job.delivery_id,
|
|
85
|
+
pipelineId: job.pipeline_id,
|
|
86
|
+
attempt: job.attempts,
|
|
87
|
+
headers,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const response = await adapter.send(payload, destConfig, context);
|
|
92
|
+
const durationMs = Date.now() - startedAt;
|
|
93
|
+
|
|
94
|
+
if (response?.success === false) {
|
|
95
|
+
const error = new Error(response.error || `HTTP ${response.statusCode}`);
|
|
96
|
+
error.statusCode = response.statusCode;
|
|
97
|
+
error.responseBody = response.responseBody;
|
|
98
|
+
insertAttempt(this.#db, {
|
|
99
|
+
id: ulid(),
|
|
100
|
+
deliveryId: job.delivery_id,
|
|
101
|
+
destinationId: destConfig.id ?? destConfig.type,
|
|
102
|
+
attemptNumber: job.attempts,
|
|
103
|
+
status: 'failure',
|
|
104
|
+
statusCode: response.statusCode ?? null,
|
|
105
|
+
responseBody: response.responseBody ?? null,
|
|
106
|
+
errorMessage: error.message,
|
|
107
|
+
durationMs,
|
|
108
|
+
});
|
|
109
|
+
await this.#handleRetryOrDlq(job, destConfig, retryConfig, error);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await this.#queue.ack(job.id);
|
|
114
|
+
insertAttempt(this.#db, {
|
|
115
|
+
id: ulid(),
|
|
116
|
+
deliveryId: job.delivery_id,
|
|
117
|
+
destinationId: destConfig.id ?? destConfig.type,
|
|
118
|
+
attemptNumber: job.attempts,
|
|
119
|
+
status: 'success',
|
|
120
|
+
statusCode: response?.statusCode ?? null,
|
|
121
|
+
responseBody: response?.responseBody ?? null,
|
|
122
|
+
errorMessage: null,
|
|
123
|
+
durationMs,
|
|
124
|
+
});
|
|
125
|
+
updateDeliveryStatus(this.#db, job.delivery_id, 'delivered');
|
|
126
|
+
incrementStat(this.#db, job.pipeline_id, 'total_delivered');
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const durationMs = Date.now() - startedAt;
|
|
129
|
+
insertAttempt(this.#db, {
|
|
130
|
+
id: ulid(),
|
|
131
|
+
deliveryId: job.delivery_id,
|
|
132
|
+
destinationId: destConfig.id ?? destConfig.type,
|
|
133
|
+
attemptNumber: job.attempts,
|
|
134
|
+
status: 'failure',
|
|
135
|
+
statusCode: null,
|
|
136
|
+
responseBody: null,
|
|
137
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
138
|
+
durationMs,
|
|
139
|
+
});
|
|
140
|
+
await this.#handleRetryOrDlq(job, destConfig, retryConfig, error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async #handleRetryOrDlq(job, destConfig, retryConfig, error) {
|
|
145
|
+
// If on_failure is 'log', just log and mark completed (no retry, no DLQ)
|
|
146
|
+
if (destConfig.on_failure === 'log') {
|
|
147
|
+
await this.#queue.ack(job.id);
|
|
148
|
+
this.#logger?.warn?.({ deliveryId: job.delivery_id, error: error.message }, 'delivery failed, on_failure=log, not retrying');
|
|
149
|
+
incrementStat(this.#db, job.pipeline_id, 'total_failed');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (shouldRetry(job.attempts, retryConfig.maxAttempts)) {
|
|
154
|
+
const nextAttemptAt = getNextAttemptTime(job.attempts, retryConfig);
|
|
155
|
+
await this.#queue.nack(job.id, error, nextAttemptAt);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await this.#queue.moveToDeadLetter(job.id, error);
|
|
160
|
+
updateDeliveryStatus(this.#db, job.delivery_id, 'dead_letter');
|
|
161
|
+
incrementStat(this.#db, job.pipeline_id, 'total_failed');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function getDestinationConfig(pipeline, destinationId) {
|
|
166
|
+
// Support both array-based destinations and single destination config
|
|
167
|
+
if (Array.isArray(pipeline?.destinations)) {
|
|
168
|
+
const dest = destinationId
|
|
169
|
+
? pipeline.destinations.find((d) => d.id === destinationId)
|
|
170
|
+
: pipeline.destinations[0];
|
|
171
|
+
if (!dest?.type) {
|
|
172
|
+
throw new Error(`Pipeline ${pipeline?.id ?? 'unknown'} destination '${destinationId}' not found or missing type`);
|
|
173
|
+
}
|
|
174
|
+
return dest;
|
|
175
|
+
}
|
|
176
|
+
const destConfig = pipeline?.destination ?? pipeline?.destConfig ?? pipeline?.destinationConfig;
|
|
177
|
+
if (!destConfig?.type) {
|
|
178
|
+
throw new Error(`Pipeline ${pipeline?.id ?? 'unknown'} is missing destination config`);
|
|
179
|
+
}
|
|
180
|
+
return destConfig;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getRetryConfig(pipeline) {
|
|
184
|
+
const raw = pipeline?.retry ?? pipeline?.retryConfig ?? {};
|
|
185
|
+
return {
|
|
186
|
+
...DEFAULT_RETRY_CONFIG,
|
|
187
|
+
maxAttempts: raw.maxAttempts ?? raw.max_attempts ?? DEFAULT_RETRY_CONFIG.maxAttempts,
|
|
188
|
+
backoff: raw.backoff ?? DEFAULT_RETRY_CONFIG.backoff,
|
|
189
|
+
initialDelayMs: raw.initialDelayMs ?? raw.initial_delay_ms ?? DEFAULT_RETRY_CONFIG.initialDelayMs,
|
|
190
|
+
maxDelayMs: raw.maxDelayMs ?? raw.max_delay_ms ?? DEFAULT_RETRY_CONFIG.maxDelayMs,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { DestinationAdapter } from './interface.js';
|
|
2
|
+
import { renderTemplate } from '../templates/handlebars.js';
|
|
3
|
+
|
|
4
|
+
export class HttpDestinationAdapter extends DestinationAdapter {
|
|
5
|
+
get type() {
|
|
6
|
+
return 'http';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async send(payload, destConfig, context) {
|
|
10
|
+
const {
|
|
11
|
+
url,
|
|
12
|
+
method = 'POST',
|
|
13
|
+
headers: configHeaders = {},
|
|
14
|
+
body_template,
|
|
15
|
+
timeout_ms = 10000,
|
|
16
|
+
} = destConfig;
|
|
17
|
+
|
|
18
|
+
const body = body_template
|
|
19
|
+
? renderTemplate(body_template, { payload, headers: context.headers, context })
|
|
20
|
+
: JSON.stringify(payload);
|
|
21
|
+
|
|
22
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
23
|
+
|
|
24
|
+
for (const [key, value] of Object.entries(configHeaders)) {
|
|
25
|
+
headers[key] = value.replace(/\$\{(\w+)\}/g, (_, envVar) => process.env[envVar] ?? '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const timer = setTimeout(() => controller.abort(), timeout_ms);
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(url, {
|
|
34
|
+
method,
|
|
35
|
+
headers,
|
|
36
|
+
body,
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const responseBody = await response.text();
|
|
41
|
+
const statusCode = response.status;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
success: statusCode >= 200 && statusCode < 300,
|
|
45
|
+
statusCode,
|
|
46
|
+
responseBody,
|
|
47
|
+
durationMs: Date.now() - start,
|
|
48
|
+
};
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return {
|
|
51
|
+
success: false,
|
|
52
|
+
statusCode: 0,
|
|
53
|
+
error: err.name === 'AbortError' ? 'Request timeout' : err.message,
|
|
54
|
+
durationMs: Date.now() - start,
|
|
55
|
+
};
|
|
56
|
+
} finally {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { HttpDestinationAdapter } from './http.js';
|
|
2
|
+
|
|
3
|
+
export class DestinationRegistry {
|
|
4
|
+
constructor() {
|
|
5
|
+
this._adapters = new Map();
|
|
6
|
+
this.register(new HttpDestinationAdapter());
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
register(adapter) {
|
|
10
|
+
this._adapters.set(adapter.type, adapter);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
getAdapter(type) {
|
|
14
|
+
const adapter = this._adapters.get(type);
|
|
15
|
+
if (!adapter) {
|
|
16
|
+
throw new Error(`No destination adapter registered for type: "${type}"`);
|
|
17
|
+
}
|
|
18
|
+
return adapter;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getTypes() {
|
|
22
|
+
return [...this._adapters.keys()];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
has(type) {
|
|
26
|
+
return this._adapters.has(type);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const registry = new DestinationRegistry();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base destination adapter class.
|
|
3
|
+
*
|
|
4
|
+
* All destination adapters (HTTP, SQS, etc.) must extend this class
|
|
5
|
+
* and implement the `type` getter and `send()` method.
|
|
6
|
+
*
|
|
7
|
+
* @abstract
|
|
8
|
+
*/
|
|
9
|
+
export class DestinationAdapter {
|
|
10
|
+
/**
|
|
11
|
+
* The adapter type identifier (e.g., 'http', 'sqs').
|
|
12
|
+
* Subclasses MUST override this getter.
|
|
13
|
+
*
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
get type() {
|
|
17
|
+
throw new Error('Not implemented');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Send a payload to the destination.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} payload - The transformed webhook payload.
|
|
24
|
+
* @param {object} destConfig - Destination configuration from pipeline YAML
|
|
25
|
+
* (url, method, headers, body_template, timeout_ms, etc.).
|
|
26
|
+
* @param {object} context - Delivery context.
|
|
27
|
+
* @param {string} context.deliveryId - Unique delivery ID.
|
|
28
|
+
* @param {string} context.pipelineId - Pipeline identifier.
|
|
29
|
+
* @param {number} context.attempt - Current attempt number.
|
|
30
|
+
* @param {object} context.headers - Original webhook headers.
|
|
31
|
+
* @returns {Promise<{success: boolean, statusCode?: number, responseBody?: string, error?: string, durationMs: number}>}
|
|
32
|
+
*/
|
|
33
|
+
async send(payload, destConfig, context) {
|
|
34
|
+
throw new Error('Not implemented');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Optional health check for the destination.
|
|
39
|
+
* Subclasses may override for active health probing.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} destConfig - Destination configuration.
|
|
42
|
+
* @returns {Promise<{healthy: boolean, error?: string}>}
|
|
43
|
+
*/
|
|
44
|
+
async healthCheck(destConfig) {
|
|
45
|
+
return { healthy: true };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readdir, readFile } from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { EventEmitter } from 'events';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
import chokidar from 'chokidar';
|
|
6
|
+
|
|
7
|
+
const VALID_ID_RE = /^[a-z0-9-]+$/;
|
|
8
|
+
const ENV_VAR_RE = /\$\{([^}]+)\}/g;
|
|
9
|
+
const KNOWN_TYPES = new Set(['http']);
|
|
10
|
+
|
|
11
|
+
function interpolateEnv(value) {
|
|
12
|
+
if (typeof value === 'string') {
|
|
13
|
+
return value.replace(ENV_VAR_RE, (_, varName) => process.env[varName] || '');
|
|
14
|
+
}
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
return value.map(interpolateEnv);
|
|
17
|
+
}
|
|
18
|
+
if (value && typeof value === 'object') {
|
|
19
|
+
const result = {};
|
|
20
|
+
for (const [k, v] of Object.entries(value)) {
|
|
21
|
+
result[k] = interpolateEnv(v);
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validate(config, filePath) {
|
|
29
|
+
if (!config || !config.id) {
|
|
30
|
+
throw new Error(`Pipeline id is required (file: ${filePath})`);
|
|
31
|
+
}
|
|
32
|
+
if (!VALID_ID_RE.test(config.id)) {
|
|
33
|
+
throw new Error(`Invalid id "${config.id}" - must be URL-safe (lowercase alphanumeric + hyphens only) (file: ${filePath})`);
|
|
34
|
+
}
|
|
35
|
+
if (!config.destinations || !Array.isArray(config.destinations) || config.destinations.length === 0) {
|
|
36
|
+
throw new Error(`Pipeline destinations is required and must be a non-empty array (file: ${filePath})`);
|
|
37
|
+
}
|
|
38
|
+
for (const dest of config.destinations) {
|
|
39
|
+
if (!dest.id) {
|
|
40
|
+
throw new Error(`Destination must have an id (file: ${filePath})`);
|
|
41
|
+
}
|
|
42
|
+
if (!dest.type) {
|
|
43
|
+
throw new Error(`Destination must have a type (file: ${filePath})`);
|
|
44
|
+
}
|
|
45
|
+
if (!KNOWN_TYPES.has(dest.type)) {
|
|
46
|
+
throw new Error(`Unknown destination type "${dest.type}" (file: ${filePath})`);
|
|
47
|
+
}
|
|
48
|
+
if (!dest.url) {
|
|
49
|
+
throw new Error(`Destination must have a url (file: ${filePath})`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class PipelineLoader extends EventEmitter {
|
|
55
|
+
#pipelines = new Map();
|
|
56
|
+
#pipelinesDir;
|
|
57
|
+
#watcher = null;
|
|
58
|
+
#fileToPipelineId = new Map();
|
|
59
|
+
|
|
60
|
+
constructor(pipelinesDir, opts = {}) {
|
|
61
|
+
super();
|
|
62
|
+
this.#pipelinesDir = pipelinesDir;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async loadAll() {
|
|
66
|
+
const files = await this.#scanYamlFiles(this.#pipelinesDir);
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
await this.#loadFile(file);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get(pipelineId) {
|
|
73
|
+
return this.#pipelines.get(pipelineId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getAll() {
|
|
77
|
+
return this.#pipelines;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
startWatching() {
|
|
81
|
+
this.#watcher = chokidar.watch(this.#pipelinesDir, {
|
|
82
|
+
ignoreInitial: true,
|
|
83
|
+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.#watcher.on('add', (filePath) => this.#handleFileChange(filePath));
|
|
87
|
+
this.#watcher.on('change', (filePath) => this.#handleFileChange(filePath));
|
|
88
|
+
this.#watcher.on('unlink', (filePath) => this.#handleFileRemove(filePath));
|
|
89
|
+
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
this.#watcher.on('ready', resolve);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
stopWatching() {
|
|
96
|
+
if (this.#watcher) {
|
|
97
|
+
this.#watcher.close();
|
|
98
|
+
this.#watcher = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async #scanYamlFiles(dir) {
|
|
103
|
+
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
|
104
|
+
return entries
|
|
105
|
+
.filter((e) => e.isFile() && (e.name.endsWith('.yaml') || e.name.endsWith('.yml')))
|
|
106
|
+
.map((e) => path.join(e.parentPath || e.path, e.name));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async #loadFile(filePath) {
|
|
110
|
+
try {
|
|
111
|
+
const content = await readFile(filePath, 'utf-8');
|
|
112
|
+
const raw = yaml.load(content);
|
|
113
|
+
validate(raw, filePath);
|
|
114
|
+
const config = interpolateEnv(raw);
|
|
115
|
+
this.#pipelines.set(config.id, config);
|
|
116
|
+
this.#fileToPipelineId.set(filePath, config.id);
|
|
117
|
+
this.emit('loaded', config);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
this.emit('error', err);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async #handleFileChange(filePath) {
|
|
124
|
+
if (!filePath.endsWith('.yaml') && !filePath.endsWith('.yml')) return;
|
|
125
|
+
try {
|
|
126
|
+
const content = await readFile(filePath, 'utf-8');
|
|
127
|
+
const raw = yaml.load(content);
|
|
128
|
+
validate(raw, filePath);
|
|
129
|
+
const config = interpolateEnv(raw);
|
|
130
|
+
// Remove old pipeline ID if the file previously had a different ID
|
|
131
|
+
const oldId = this.#fileToPipelineId.get(filePath);
|
|
132
|
+
if (oldId && oldId !== config.id) {
|
|
133
|
+
this.#pipelines.delete(oldId);
|
|
134
|
+
}
|
|
135
|
+
this.#pipelines.set(config.id, config);
|
|
136
|
+
this.#fileToPipelineId.set(filePath, config.id);
|
|
137
|
+
this.emit('reloaded', config);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
this.emit('error', err);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#handleFileRemove(filePath) {
|
|
144
|
+
const pipelineId = this.#fileToPipelineId.get(filePath);
|
|
145
|
+
if (pipelineId) {
|
|
146
|
+
this.#pipelines.delete(pipelineId);
|
|
147
|
+
this.#fileToPipelineId.delete(filePath);
|
|
148
|
+
this.emit('removed', pipelineId);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Loads CJS transform/filter plugins from a specified directory.
|
|
7
|
+
*
|
|
8
|
+
* Plugin API contracts:
|
|
9
|
+
* - Transform: `module.exports.transform = function(payload, headers, config) -> object|null`
|
|
10
|
+
* - Filter: `module.exports.filter = function(payload, headers, config) -> { pass: boolean, reason?: string }`
|
|
11
|
+
*/
|
|
12
|
+
export class PluginLoader {
|
|
13
|
+
#pluginsDir;
|
|
14
|
+
#cache = new Map();
|
|
15
|
+
#require;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} pluginsDir - Absolute path to the plugins directory
|
|
19
|
+
*/
|
|
20
|
+
constructor(pluginsDir) {
|
|
21
|
+
this.#pluginsDir = pluginsDir;
|
|
22
|
+
this.#require = createRequire(import.meta.url);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load a transform plugin. Returns the module's `transform` function.
|
|
27
|
+
* @param {string} relativePath - Path relative to pluginsDir
|
|
28
|
+
* @returns {function(object, object, object): object|null}
|
|
29
|
+
*/
|
|
30
|
+
loadTransform(relativePath) {
|
|
31
|
+
const mod = this.#loadModule(relativePath);
|
|
32
|
+
if (typeof mod.transform !== 'function') {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Plugin "${relativePath}" does not export a transform function`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return mod.transform;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load a filter plugin. Returns the module's `filter` function.
|
|
42
|
+
* @param {string} relativePath - Path relative to pluginsDir
|
|
43
|
+
* @returns {function(object, object, object): { pass: boolean, reason?: string }}
|
|
44
|
+
*/
|
|
45
|
+
loadFilter(relativePath) {
|
|
46
|
+
const mod = this.#loadModule(relativePath);
|
|
47
|
+
if (typeof mod.filter !== 'function') {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Plugin "${relativePath}" does not export a filter function`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return mod.filter;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Clear the plugin cache (for hot-reload support). */
|
|
56
|
+
clearCache() {
|
|
57
|
+
for (const resolvedPath of this.#cache.keys()) {
|
|
58
|
+
delete this.#require.cache[resolvedPath];
|
|
59
|
+
}
|
|
60
|
+
this.#cache.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if a plugin is cached.
|
|
65
|
+
* @param {string} relativePath - Path relative to pluginsDir
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
isLoaded(relativePath) {
|
|
69
|
+
const resolved = path.resolve(this.#pluginsDir, relativePath);
|
|
70
|
+
return this.#cache.has(resolved);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#loadModule(relativePath) {
|
|
74
|
+
const resolved = path.resolve(this.#pluginsDir, relativePath);
|
|
75
|
+
|
|
76
|
+
if (this.#cache.has(resolved)) {
|
|
77
|
+
return this.#cache.get(resolved);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!fs.existsSync(resolved)) {
|
|
81
|
+
throw new Error(`Plugin not found: ${resolved}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const mod = this.#require(resolved);
|
|
85
|
+
this.#cache.set(resolved, mod);
|
|
86
|
+
return mod;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract base class defining the queue contract for hookpipe.
|
|
3
|
+
* All queue implementations (SQLite, PostgreSQL, etc.) must extend this class
|
|
4
|
+
* and implement every method.
|
|
5
|
+
*/
|
|
6
|
+
export class QueueInterface {
|
|
7
|
+
/**
|
|
8
|
+
* Add a job to the queue.
|
|
9
|
+
* @param {Object} job - The job to enqueue
|
|
10
|
+
* @param {string} job.deliveryId - Unique delivery identifier
|
|
11
|
+
* @param {string} job.destinationId - Target destination ID
|
|
12
|
+
* @param {string} job.pipelineId - Pipeline this job belongs to
|
|
13
|
+
* @param {Object} job.payload - The webhook payload body
|
|
14
|
+
* @param {Object} job.headers - Original request headers
|
|
15
|
+
* @param {number} job.maxAttempts - Maximum retry attempts allowed
|
|
16
|
+
* @returns {Promise<Object>} The created job record
|
|
17
|
+
*/
|
|
18
|
+
async enqueue(job) {
|
|
19
|
+
throw new Error('QueueInterface.enqueue() not implemented');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetch up to `limit` ready jobs (status=pending, next_attempt_at <= now).
|
|
24
|
+
* @param {number} [limit=1] - Maximum number of jobs to dequeue
|
|
25
|
+
* @returns {Promise<Array<Object>>} Array of job objects ready for processing
|
|
26
|
+
*/
|
|
27
|
+
async dequeue(limit = 1) {
|
|
28
|
+
throw new Error('QueueInterface.dequeue() not implemented');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mark a job as completed successfully.
|
|
33
|
+
* @param {string} jobId - The job ID to acknowledge
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
async ack(jobId) {
|
|
37
|
+
throw new Error('QueueInterface.ack() not implemented');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Mark a job as failed and schedule a retry.
|
|
42
|
+
* @param {string} jobId - The job ID that failed
|
|
43
|
+
* @param {Error} error - The error that occurred
|
|
44
|
+
* @param {Date} nextAttemptAt - When to retry the job
|
|
45
|
+
* @returns {Promise<void>}
|
|
46
|
+
*/
|
|
47
|
+
async nack(jobId, error, nextAttemptAt) {
|
|
48
|
+
throw new Error('QueueInterface.nack() not implemented');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Move an exhausted job to the dead letter queue.
|
|
53
|
+
* @param {string} jobId - The job ID to move
|
|
54
|
+
* @param {Error} error - The final error
|
|
55
|
+
* @returns {Promise<void>}
|
|
56
|
+
*/
|
|
57
|
+
async moveToDeadLetter(jobId, error) {
|
|
58
|
+
throw new Error('QueueInterface.moveToDeadLetter() not implemented');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Return queue statistics.
|
|
63
|
+
* @returns {Promise<{pending: number, processing: number, completed: number, failed: number, deadLetter: number}>}
|
|
64
|
+
*/
|
|
65
|
+
async getStats() {
|
|
66
|
+
throw new Error('QueueInterface.getStats() not implemented');
|
|
67
|
+
}
|
|
68
|
+
}
|