@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.
@@ -0,0 +1,83 @@
1
+ import { QueueInterface } from './interface.js';
2
+ import { insertQueueJob, dequeueJobs, ackJob, nackJob, moveJobToDeadLetter, insertDeadLetter } from '../db/queries.js';
3
+ import { ulid } from 'ulid';
4
+
5
+ export class SqliteQueue extends QueueInterface {
6
+ #db;
7
+ #config;
8
+
9
+ constructor(db, config = {}) {
10
+ super();
11
+ this.#db = db;
12
+ this.#config = {
13
+ pollIntervalMs: config.pollIntervalMs ?? 1000,
14
+ concurrency: config.concurrency ?? 5,
15
+ };
16
+ }
17
+
18
+ async enqueue(job) {
19
+ const id = ulid();
20
+ insertQueueJob(this.#db, {
21
+ id,
22
+ deliveryId: job.deliveryId,
23
+ destinationId: job.destinationId,
24
+ pipelineId: job.pipelineId,
25
+ payload: job.payload,
26
+ headers: job.headers,
27
+ maxAttempts: job.maxAttempts,
28
+ });
29
+ return { id, ...job };
30
+ }
31
+
32
+ async dequeue(limit = 1) {
33
+ const lockId = ulid();
34
+ return dequeueJobs(this.#db, limit, lockId);
35
+ }
36
+
37
+ async ack(jobId) {
38
+ ackJob(this.#db, jobId);
39
+ }
40
+
41
+ async nack(jobId, error, nextAttemptAt) {
42
+ const errorMessage = error instanceof Error ? error.message : String(error);
43
+ nackJob(this.#db, jobId, errorMessage, nextAttemptAt);
44
+ }
45
+
46
+ async moveToDeadLetter(jobId, error) {
47
+ const job = this.#db.prepare('SELECT * FROM queue_jobs WHERE id = ?').get(jobId);
48
+ if (!job) throw new Error(`Job not found: ${jobId}`);
49
+
50
+ const errorMessage = error instanceof Error ? error.message : String(error);
51
+
52
+ insertDeadLetter(this.#db, {
53
+ id: ulid(),
54
+ deliveryId: job.delivery_id,
55
+ pipelineId: job.pipeline_id,
56
+ destinationId: job.destination_id,
57
+ payload: JSON.parse(job.payload),
58
+ headers: JSON.parse(job.headers),
59
+ errorMessage,
60
+ attempts: job.attempts,
61
+ });
62
+
63
+ moveJobToDeadLetter(this.#db, jobId);
64
+ }
65
+
66
+ async getStats() {
67
+ const rows = this.#db.prepare(`
68
+ SELECT status, COUNT(*) as count FROM queue_jobs GROUP BY status
69
+ `).all();
70
+
71
+ const stats = { pending: 0, processing: 0, completed: 0, failed: 0, deadLetter: 0 };
72
+ for (const row of rows) {
73
+ switch (row.status) {
74
+ case 'pending': stats.pending = row.count; break;
75
+ case 'processing': stats.processing = row.count; break;
76
+ case 'completed': stats.completed = row.count; break;
77
+ case 'failed': stats.failed = row.count; break;
78
+ case 'dead_letter': stats.deadLetter = row.count; break;
79
+ }
80
+ }
81
+ return stats;
82
+ }
83
+ }
package/src/server.js ADDED
@@ -0,0 +1,124 @@
1
+ import Fastify from 'fastify';
2
+ import { ulid } from 'ulid';
3
+ import { verifyWebhookAuth } from './auth/hmac.js';
4
+ import { insertDelivery, incrementStat } from './db/queries.js';
5
+
6
+ function getPluginPath(pluginConfig) {
7
+ return typeof pluginConfig === 'string' ? pluginConfig : pluginConfig.path;
8
+ }
9
+
10
+ function getPluginConfig(pluginConfig) {
11
+ return typeof pluginConfig === 'string' ? undefined : pluginConfig.config;
12
+ }
13
+
14
+ function getPipelineDestinations(pipeline) {
15
+ if (Array.isArray(pipeline.destinations)) {
16
+ return pipeline.destinations;
17
+ }
18
+ return pipeline.destination ? [pipeline.destination] : [];
19
+ }
20
+
21
+ function getMaxAttempts(pipeline) {
22
+ return pipeline.retry?.maxAttempts ?? pipeline.retry?.max_attempts ?? 3;
23
+ }
24
+
25
+ export function createServer(deps) {
26
+ const { pipelineLoader, pluginLoader, queue, db, logger } = deps;
27
+ const server = Fastify({ logger: false });
28
+
29
+ server.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
30
+ req.rawBody = body;
31
+ try {
32
+ done(null, JSON.parse(body));
33
+ } catch (error) {
34
+ done(error);
35
+ }
36
+ });
37
+
38
+ server.get('/health', async () => ({
39
+ status: 'ok',
40
+ uptime: process.uptime(),
41
+ timestamp: new Date().toISOString(),
42
+ }));
43
+
44
+ server.post('/hook/:pipelineId', async (request, reply) => {
45
+ try {
46
+ const { pipelineId } = request.params;
47
+ const pipeline = await pipelineLoader.get(pipelineId);
48
+
49
+ if (!pipeline) {
50
+ return reply.code(404).send({ error: 'Pipeline not found' });
51
+ }
52
+
53
+ if (pipeline.auth) {
54
+ const authResult = verifyWebhookAuth(request.rawBody ?? request.raw.rawBody, request.headers, pipeline.auth);
55
+ if (!authResult.valid) {
56
+ return reply.code(401).send({
57
+ error: 'Authentication failed',
58
+ details: authResult.error,
59
+ });
60
+ }
61
+ }
62
+
63
+ let payload = request.body;
64
+
65
+ if (pipeline.filter) {
66
+ const filter = await pluginLoader.loadFilter(getPluginPath(pipeline.filter));
67
+ const filterResult = await filter(payload, request.headers, pipeline.filter_config || getPluginConfig(pipeline.filter) || {});
68
+ if (filterResult?.pass === false) {
69
+ incrementStat(db, pipeline.id ?? pipelineId, 'total_filtered');
70
+ return reply.code(200).send({
71
+ status: 'filtered',
72
+ reason: filterResult.reason,
73
+ });
74
+ }
75
+ }
76
+
77
+ if (pipeline.transform) {
78
+ const transform = await pluginLoader.loadTransform(getPluginPath(pipeline.transform));
79
+ const transformed = await transform(payload, request.headers, pipeline.filter_config || getPluginConfig(pipeline.transform) || {});
80
+ if (transformed === null) {
81
+ incrementStat(db, pipeline.id ?? pipelineId, 'total_filtered');
82
+ return reply.code(200).send({
83
+ status: 'filtered',
84
+ reason: 'Transform returned null',
85
+ });
86
+ }
87
+ payload = transformed;
88
+ }
89
+
90
+ const deliveryId = ulid();
91
+ const resolvedPipelineId = pipeline.id ?? pipelineId;
92
+ const headers = request.headers;
93
+
94
+ insertDelivery(db, {
95
+ id: deliveryId,
96
+ pipelineId: resolvedPipelineId,
97
+ payload,
98
+ headers,
99
+ sourceIp: request.ip,
100
+ status: 'queued',
101
+ });
102
+
103
+ for (const destination of getPipelineDestinations(pipeline)) {
104
+ await queue.enqueue({
105
+ deliveryId,
106
+ destinationId: destination.id,
107
+ pipelineId: resolvedPipelineId,
108
+ payload,
109
+ headers,
110
+ maxAttempts: getMaxAttempts(pipeline),
111
+ });
112
+ }
113
+
114
+ incrementStat(db, resolvedPipelineId, 'total_received');
115
+
116
+ return reply.code(200).send({ status: 'accepted', deliveryId });
117
+ } catch (error) {
118
+ logger?.error?.(error, 'Webhook ingestion failed');
119
+ return reply.code(500).send({ error: 'Internal server error' });
120
+ }
121
+ });
122
+
123
+ return server;
124
+ }
@@ -0,0 +1,21 @@
1
+ import Handlebars from 'handlebars';
2
+
3
+ const templateCache = new Map();
4
+
5
+ Handlebars.registerHelper('json', (context) => new Handlebars.SafeString(JSON.stringify(context)));
6
+ Handlebars.registerHelper('upper', (str) => str.toUpperCase());
7
+ Handlebars.registerHelper('lower', (str) => str.toLowerCase());
8
+ Handlebars.registerHelper('default', (value, fallback) => value || fallback);
9
+
10
+ export function renderTemplate(templateString, data) {
11
+ let compiledTemplate = templateCache.get(templateString);
12
+ if (!compiledTemplate) {
13
+ compiledTemplate = Handlebars.compile(templateString);
14
+ templateCache.set(templateString, compiledTemplate);
15
+ }
16
+ return compiledTemplate(data);
17
+ }
18
+
19
+ export function registerHelper(name, fn) {
20
+ Handlebars.registerHelper(name, fn);
21
+ }
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import dotenv from 'dotenv';
5
+
6
+ dotenv.config();
7
+
8
+ const DEFAULTS = {
9
+ host: '0.0.0.0',
10
+ port: 3000,
11
+ db: { path: './data/hookpipe.db' },
12
+ log: { level: 'info' },
13
+ pipelines: { dir: './pipelines' },
14
+ plugins: { dir: './plugins' },
15
+ retry: { maxAttempts: 3, backoff: 'exponential', initialDelayMs: 1000, maxDelayMs: 300000 },
16
+ queue: { pollIntervalMs: 1000, concurrency: 5 },
17
+ };
18
+
19
+ const ENV_MAP = {
20
+ HOOKPIPE_PORT: 'port',
21
+ HOOKPIPE_HOST: 'host',
22
+ HOOKPIPE_DB_PATH: 'db.path',
23
+ HOOKPIPE_LOG_LEVEL: 'log.level',
24
+ HOOKPIPE_PIPELINES_DIR: 'pipelines.dir',
25
+ HOOKPIPE_PLUGINS_DIR: 'plugins.dir',
26
+ };
27
+
28
+ function deepMerge(target, source) {
29
+ const result = { ...target };
30
+ for (const key of Object.keys(source)) {
31
+ if (
32
+ source[key] !== null &&
33
+ typeof source[key] === 'object' &&
34
+ !Array.isArray(source[key]) &&
35
+ typeof target[key] === 'object' &&
36
+ target[key] !== null
37
+ ) {
38
+ result[key] = deepMerge(target[key], source[key]);
39
+ } else {
40
+ result[key] = source[key];
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+
46
+ function interpolateEnv(obj) {
47
+ if (typeof obj === 'string') {
48
+ return obj.replace(/\$\{([^}]+)\}/g, (_, varName) => process.env[varName] ?? '');
49
+ }
50
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
51
+ const result = {};
52
+ for (const [key, value] of Object.entries(obj)) {
53
+ result[key] = interpolateEnv(value);
54
+ }
55
+ return result;
56
+ }
57
+ if (Array.isArray(obj)) {
58
+ return obj.map(interpolateEnv);
59
+ }
60
+ return obj;
61
+ }
62
+
63
+ function setNested(obj, dotPath, value) {
64
+ const keys = dotPath.split('.');
65
+ const last = keys.pop();
66
+ let current = obj;
67
+ for (const key of keys) {
68
+ if (!(key in current) || typeof current[key] !== 'object') {
69
+ current[key] = {};
70
+ }
71
+ current = current[key];
72
+ }
73
+ current[last] = value;
74
+ }
75
+
76
+ function loadYaml(filePath) {
77
+ try {
78
+ const content = fs.readFileSync(filePath, 'utf8');
79
+ return yaml.load(content) || {};
80
+ } catch {
81
+ return {};
82
+ }
83
+ }
84
+
85
+ function resolveConfigPath(overridePath) {
86
+ if (overridePath) return overridePath;
87
+ if (process.env.HOOKPIPE_CONFIG) return process.env.HOOKPIPE_CONFIG;
88
+ return path.join(process.cwd(), 'hookpipe.config.yaml');
89
+ }
90
+
91
+ function applyEnv() {
92
+ const envConfig = {};
93
+ for (const [envVar, configPath] of Object.entries(ENV_MAP)) {
94
+ if (process.env[envVar] !== undefined) {
95
+ let value = process.env[envVar];
96
+ if (configPath === 'port') value = Number(value);
97
+ setNested(envConfig, configPath, value);
98
+ }
99
+ }
100
+ return envConfig;
101
+ }
102
+
103
+ export function loadConfig(overrides = {}) {
104
+ const { _configPath, configPath, ...cliOverrides } = overrides;
105
+
106
+ const configFilePath = resolveConfigPath(_configPath || configPath);
107
+ const fileConfig = interpolateEnv(loadYaml(configFilePath));
108
+ const envConfig = applyEnv();
109
+
110
+ let config = deepMerge(DEFAULTS, fileConfig);
111
+ config = deepMerge(config, envConfig);
112
+ config = deepMerge(config, cliOverrides);
113
+
114
+ return config;
115
+ }
@@ -0,0 +1,44 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+
3
+ /**
4
+ * Constant-time string comparison to prevent timing attacks.
5
+ */
6
+ export function timingSafeCompare(a, b) {
7
+ const bufA = Buffer.from(a);
8
+ const bufB = Buffer.from(b);
9
+
10
+ if (bufA.length !== bufB.length) {
11
+ // Still do a comparison to avoid leaking length info via timing
12
+ timingSafeEqual(bufA, bufA);
13
+ return false;
14
+ }
15
+
16
+ return timingSafeEqual(bufA, bufB);
17
+ }
18
+
19
+ /**
20
+ * Compute HMAC hex digest for a given algorithm, secret, and payload.
21
+ */
22
+ export function computeHmac(algorithm, secret, payload) {
23
+ return createHmac(algorithm, secret).update(payload).digest('hex');
24
+ }
25
+
26
+ /**
27
+ * Verify a webhook signature against a computed HMAC.
28
+ *
29
+ * @param {object} opts
30
+ * @param {'sha256'|'sha1'} opts.algorithm
31
+ * @param {string} opts.secret
32
+ * @param {string|Buffer} opts.payload
33
+ * @param {string} opts.signature - The signature header value
34
+ * @param {string} [opts.prefix] - Prefix to strip (e.g., 'sha256=')
35
+ * @returns {boolean}
36
+ */
37
+ export function verifySignature({ algorithm, secret, payload, signature, prefix }) {
38
+ const expected = computeHmac(algorithm, secret, payload);
39
+ const actual = prefix && signature.startsWith(prefix)
40
+ ? signature.slice(prefix.length)
41
+ : signature;
42
+
43
+ return timingSafeCompare(expected, actual);
44
+ }
@@ -0,0 +1,35 @@
1
+ import pino from 'pino';
2
+
3
+ const DEFAULT_REDACT_PATHS = ['*.secret', '*.password', '*.token'];
4
+
5
+ /**
6
+ * Create a configured pino logger instance.
7
+ * @param {object} [opts] - Options
8
+ * @param {string} [opts.level] - Log level (defaults to HOOKPIPE_LOG_LEVEL env or 'info')
9
+ * @param {string[]} [opts.redact] - Paths to redact
10
+ * @param {object} [opts.stream] - Custom writable stream (for testing)
11
+ * @returns {import('pino').Logger}
12
+ */
13
+ export function createLogger(opts = {}) {
14
+ const {
15
+ level = process.env.HOOKPIPE_LOG_LEVEL || 'info',
16
+ redact = DEFAULT_REDACT_PATHS,
17
+ stream,
18
+ ...rest
19
+ } = opts;
20
+
21
+ const config = {
22
+ level,
23
+ redact: {
24
+ paths: redact,
25
+ censor: '[Redacted]',
26
+ },
27
+ ...rest,
28
+ };
29
+
30
+ return stream ? pino(config, stream) : pino(config);
31
+ }
32
+
33
+ const logger = createLogger();
34
+
35
+ export default logger;