@manablox/jobs 0.1.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/package.json +28 -0
- package/src/index.ts +231 -0
- package/tsconfig.json +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@manablox/jobs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"default": "./src/index.ts"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"main": "./src/index.ts",
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@manablox/core": "0.1.0",
|
|
15
|
+
"@manablox/db": "0.1.0",
|
|
16
|
+
"bullmq": "^6.3.4",
|
|
17
|
+
"ioredis": "^6.0.0",
|
|
18
|
+
"drizzle-orm": "^0.45.2"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@manablox/config-typescript": "0.0.0",
|
|
22
|
+
"@types/node": "^26.4.1",
|
|
23
|
+
"typescript": "^7.0.2"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import type { Manablox } from '@manablox/core';
|
|
3
|
+
import { type Database, type Repositories, schema } from '@manablox/db';
|
|
4
|
+
import { type JobsOptions, Queue, Worker } from 'bullmq';
|
|
5
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
6
|
+
import { Redis } from 'ioredis';
|
|
7
|
+
|
|
8
|
+
export interface JobPayloads {
|
|
9
|
+
'webhook:deliver': { webhookId: string; event: string; payload: Record<string, unknown> };
|
|
10
|
+
'media:derive': { assetId: string; preset: string; format: string };
|
|
11
|
+
'content:scheduledPublish': { contentId: string };
|
|
12
|
+
'maintenance:pruneApiKeys': Record<string, never>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type JobName = keyof JobPayloads;
|
|
16
|
+
|
|
17
|
+
export interface JobRunner {
|
|
18
|
+
enqueue<K extends JobName>(
|
|
19
|
+
name: K,
|
|
20
|
+
payload: JobPayloads[K],
|
|
21
|
+
options?: JobsOptions,
|
|
22
|
+
): Promise<void>;
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface JobHandlers {
|
|
27
|
+
derive?: (assetId: string, preset: string, format: string) => Promise<void>;
|
|
28
|
+
publish?: (contentId: string) => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Background work: webhook delivery with retries, image derivatives, scheduled publish. */
|
|
32
|
+
export function createJobRunner(
|
|
33
|
+
manablox: Manablox,
|
|
34
|
+
db: Database,
|
|
35
|
+
repos: Repositories,
|
|
36
|
+
handlers: JobHandlers = {},
|
|
37
|
+
): JobRunner {
|
|
38
|
+
const redisUrl = manablox.config.cache.redisUrl;
|
|
39
|
+
|
|
40
|
+
// Without Redis, jobs run inline. Correct for a single-node install, and it keeps the
|
|
41
|
+
// call sites identical either way.
|
|
42
|
+
if (!redisUrl) {
|
|
43
|
+
return {
|
|
44
|
+
async enqueue(name, payload) {
|
|
45
|
+
await runJob(name, payload as never, db, repos, handlers, manablox);
|
|
46
|
+
},
|
|
47
|
+
async close() {},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const connection = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
52
|
+
const queue = new Queue('manablox', { connection });
|
|
53
|
+
|
|
54
|
+
const worker = new Worker(
|
|
55
|
+
'manablox',
|
|
56
|
+
async (job) => {
|
|
57
|
+
await runJob(job.name as JobName, job.data, db, repos, handlers, manablox);
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
connection,
|
|
61
|
+
concurrency: 8,
|
|
62
|
+
// Exponential backoff so a webhook endpoint that is briefly down is retried
|
|
63
|
+
// rather than dropped, and a permanently dead one stops being hammered.
|
|
64
|
+
autorun: true,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
worker.on('failed', (job, error) => {
|
|
69
|
+
manablox.logger.warn({ job: job?.name, err: error }, 'job failed');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
manablox.onDispose(async () => {
|
|
73
|
+
await worker.close();
|
|
74
|
+
await queue.close();
|
|
75
|
+
connection.disconnect();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
async enqueue(name, payload, options) {
|
|
80
|
+
await queue.add(name, payload, {
|
|
81
|
+
attempts: 5,
|
|
82
|
+
backoff: { type: 'exponential', delay: 2000 },
|
|
83
|
+
removeOnComplete: 500,
|
|
84
|
+
removeOnFail: 1000,
|
|
85
|
+
...options,
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
async close() {
|
|
89
|
+
await worker.close();
|
|
90
|
+
await queue.close();
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function runJob(
|
|
96
|
+
name: JobName,
|
|
97
|
+
payload: JobPayloads[JobName],
|
|
98
|
+
db: Database,
|
|
99
|
+
repos: Repositories,
|
|
100
|
+
handlers: JobHandlers,
|
|
101
|
+
manablox: Manablox,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
switch (name) {
|
|
104
|
+
case 'webhook:deliver': {
|
|
105
|
+
const data = payload as JobPayloads['webhook:deliver'];
|
|
106
|
+
await deliverWebhook(db, data, manablox);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'media:derive': {
|
|
110
|
+
const data = payload as JobPayloads['media:derive'];
|
|
111
|
+
await handlers.derive?.(data.assetId, data.preset, data.format);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case 'content:scheduledPublish': {
|
|
115
|
+
const data = payload as JobPayloads['content:scheduledPublish'];
|
|
116
|
+
await handlers.publish?.(data.contentId);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case 'maintenance:pruneApiKeys': {
|
|
120
|
+
await db
|
|
121
|
+
.delete(schema.apikeys)
|
|
122
|
+
.where(
|
|
123
|
+
sql`${schema.apikeys.expiresAt} is not null and ${schema.apikeys.expiresAt} < now()`,
|
|
124
|
+
);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
void repos;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function deliverWebhook(
|
|
132
|
+
db: Database,
|
|
133
|
+
data: JobPayloads['webhook:deliver'],
|
|
134
|
+
manablox: Manablox,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const rows = await db
|
|
137
|
+
.select()
|
|
138
|
+
.from(schema.webhooks)
|
|
139
|
+
.where(and(eq(schema.webhooks.id, data.webhookId), eq(schema.webhooks.enabled, true)))
|
|
140
|
+
.limit(1);
|
|
141
|
+
|
|
142
|
+
const webhook = rows[0];
|
|
143
|
+
if (!webhook) return;
|
|
144
|
+
|
|
145
|
+
const body = JSON.stringify({
|
|
146
|
+
event: data.event,
|
|
147
|
+
payload: data.payload,
|
|
148
|
+
at: new Date().toISOString(),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const headers: Record<string, string> = {
|
|
152
|
+
'content-type': 'application/json',
|
|
153
|
+
'x-manablox-event': data.event,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Signed so a receiver can verify the payload actually came from this instance.
|
|
157
|
+
if (webhook.secret) {
|
|
158
|
+
headers['x-manablox-signature'] = createHmac('sha256', webhook.secret)
|
|
159
|
+
.update(body)
|
|
160
|
+
.digest('hex');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let status: number | null = null;
|
|
164
|
+
let error: string | null = null;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const response = await fetch(webhook.url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers,
|
|
170
|
+
body,
|
|
171
|
+
signal: AbortSignal.timeout(10_000),
|
|
172
|
+
});
|
|
173
|
+
status = response.status;
|
|
174
|
+
if (!response.ok) error = `HTTP ${response.status}`;
|
|
175
|
+
} catch (cause) {
|
|
176
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await db.insert(schema.webhookDeliveries).values({
|
|
180
|
+
webhookId: webhook.id,
|
|
181
|
+
event: data.event,
|
|
182
|
+
payload: data.payload,
|
|
183
|
+
status,
|
|
184
|
+
error,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Throwing hands the retry decision back to BullMQ's backoff policy.
|
|
188
|
+
if (error) {
|
|
189
|
+
manablox.logger.warn({ webhook: webhook.id, status, error }, 'webhook delivery failed');
|
|
190
|
+
throw new Error(error);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Fans content lifecycle events out to the space's webhooks. */
|
|
195
|
+
export function attachWebhooks(manablox: Manablox, db: Database, jobs: JobRunner): void {
|
|
196
|
+
const dispatch = async (event: string, spaceId: string, payload: Record<string, unknown>) => {
|
|
197
|
+
const hooks = await db
|
|
198
|
+
.select({ id: schema.webhooks.id, events: schema.webhooks.events })
|
|
199
|
+
.from(schema.webhooks)
|
|
200
|
+
.where(and(eq(schema.webhooks.spaceId, spaceId), eq(schema.webhooks.enabled, true)));
|
|
201
|
+
|
|
202
|
+
for (const hook of hooks) {
|
|
203
|
+
if (hook.events.length > 0 && !hook.events.includes(event)) continue;
|
|
204
|
+
await jobs.enqueue('webhook:deliver', { webhookId: hook.id, event, payload });
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
manablox.hooks.on(
|
|
209
|
+
'content:afterPublish',
|
|
210
|
+
async (row) => {
|
|
211
|
+
await dispatch('content.published', row.spaceId, { id: row.id, permalink: row.permalink });
|
|
212
|
+
},
|
|
213
|
+
{ source: '@manablox/jobs' },
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
manablox.hooks.on(
|
|
217
|
+
'content:afterUpdate',
|
|
218
|
+
async (row) => {
|
|
219
|
+
await dispatch('content.updated', row.spaceId, { id: row.id });
|
|
220
|
+
},
|
|
221
|
+
{ source: '@manablox/jobs' },
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
manablox.hooks.on(
|
|
225
|
+
'content:afterDelete',
|
|
226
|
+
async (payload, context) => {
|
|
227
|
+
if (context.spaceId) await dispatch('content.deleted', context.spaceId, { id: payload.id });
|
|
228
|
+
},
|
|
229
|
+
{ source: '@manablox/jobs' },
|
|
230
|
+
);
|
|
231
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "@manablox/config-typescript/library.json", "include": ["src"] }
|