@manablox/jobs 0.1.0 → 0.3.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/README.md +18 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +82 -0
- package/package.json +14 -7
- package/src/index.ts +0 -231
- package/tsconfig.json +0 -1
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# `@manablox/jobs`
|
|
2
|
+
|
|
3
|
+
Background work on BullMQ: webhook delivery with retries, image derivatives, scheduled publishing, key pruning. Without Redis the jobs run inline, which is correct for a single-node install.
|
|
4
|
+
|
|
5
|
+
## Exports
|
|
6
|
+
|
|
7
|
+
- `createJobRunner`, `JobRunner`, `JobPayloads`
|
|
8
|
+
- `attachWebhooks` — turns content hooks into `webhook:deliver` jobs
|
|
9
|
+
|
|
10
|
+
## Depends on
|
|
11
|
+
|
|
12
|
+
@manablox/db, bullmq
|
|
13
|
+
|
|
14
|
+
## Check
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pnpm --filter @manablox/jobs typecheck
|
|
18
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Database, Repositories } from "@manablox/db";
|
|
2
|
+
import { JobsOptions } from "bullmq";
|
|
3
|
+
import { Manablox } from "@manablox/core";
|
|
4
|
+
//#region src/index.d.ts
|
|
5
|
+
export interface JobPayloads {
|
|
6
|
+
'webhook:deliver': {
|
|
7
|
+
webhookId: string;
|
|
8
|
+
event: string;
|
|
9
|
+
payload: Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
'media:derive': {
|
|
12
|
+
assetId: string;
|
|
13
|
+
preset: string;
|
|
14
|
+
format: string;
|
|
15
|
+
};
|
|
16
|
+
'content:scheduledPublish': {
|
|
17
|
+
contentId: string;
|
|
18
|
+
};
|
|
19
|
+
'workflow:run': {
|
|
20
|
+
runId: string;
|
|
21
|
+
};
|
|
22
|
+
'maintenance:pruneApiKeys': Record<string, never>;
|
|
23
|
+
}
|
|
24
|
+
export type JobName = keyof JobPayloads;
|
|
25
|
+
export interface JobRunner {
|
|
26
|
+
enqueue<K extends JobName>(name: K, payload: JobPayloads[K], options?: JobsOptions): Promise<void>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
/** True when there is no queue and `enqueue` runs the job before returning. */
|
|
29
|
+
inline: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface JobHandlers {
|
|
32
|
+
derive?: (assetId: string, preset: string, format: string) => Promise<void>;
|
|
33
|
+
publish?: (contentId: string) => Promise<void>;
|
|
34
|
+
/** Executes a queued workflow run; set by the host once the engine exists. */
|
|
35
|
+
workflowRun?: (runId: string) => Promise<void>;
|
|
36
|
+
/** Sends one webhook delivery; set by the host once the webhook service exists. */
|
|
37
|
+
webhookDeliver?: (data: JobPayloads['webhook:deliver']) => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/** Background work: webhook delivery with retries, image derivatives, scheduled publish. */
|
|
40
|
+
export declare function createJobRunner(manablox: Manablox, db: Database, repos: Repositories, handlers?: JobHandlers): JobRunner;
|
|
41
|
+
//#endregion
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { schema } from "@manablox/db";
|
|
2
|
+
import { Queue, Worker } from "bullmq";
|
|
3
|
+
import { sql } from "drizzle-orm";
|
|
4
|
+
import { Redis } from "ioredis";
|
|
5
|
+
//#region src/index.ts
|
|
6
|
+
/** Background work: webhook delivery with retries, image derivatives, scheduled publish. */
|
|
7
|
+
function createJobRunner(manablox, db, repos, handlers = {}) {
|
|
8
|
+
const redisUrl = manablox.config.cache.redisUrl;
|
|
9
|
+
if (!redisUrl) return {
|
|
10
|
+
inline: true,
|
|
11
|
+
async enqueue(name, payload) {
|
|
12
|
+
await runJob(name, payload, db, repos, handlers, manablox);
|
|
13
|
+
},
|
|
14
|
+
async close() {}
|
|
15
|
+
};
|
|
16
|
+
const connection = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
17
|
+
const queue = new Queue("manablox", { connection });
|
|
18
|
+
const worker = new Worker("manablox", async (job) => {
|
|
19
|
+
await runJob(job.name, job.data, db, repos, handlers, manablox);
|
|
20
|
+
}, {
|
|
21
|
+
connection,
|
|
22
|
+
concurrency: 8,
|
|
23
|
+
autorun: true
|
|
24
|
+
});
|
|
25
|
+
worker.on("failed", (job, error) => {
|
|
26
|
+
manablox.logger.warn({
|
|
27
|
+
job: job?.name,
|
|
28
|
+
err: error
|
|
29
|
+
}, "job failed");
|
|
30
|
+
});
|
|
31
|
+
manablox.onDispose(async () => {
|
|
32
|
+
await worker.close();
|
|
33
|
+
await queue.close();
|
|
34
|
+
connection.disconnect();
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
inline: false,
|
|
38
|
+
async enqueue(name, payload, options) {
|
|
39
|
+
await queue.add(name, payload, {
|
|
40
|
+
attempts: 5,
|
|
41
|
+
backoff: {
|
|
42
|
+
type: "exponential",
|
|
43
|
+
delay: 2e3
|
|
44
|
+
},
|
|
45
|
+
removeOnComplete: 500,
|
|
46
|
+
removeOnFail: 1e3,
|
|
47
|
+
...options
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
async close() {
|
|
51
|
+
await worker.close();
|
|
52
|
+
await queue.close();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function runJob(name, payload, db, repos, handlers, manablox) {
|
|
57
|
+
switch (name) {
|
|
58
|
+
case "webhook:deliver": {
|
|
59
|
+
const data = payload;
|
|
60
|
+
await handlers.webhookDeliver?.(data);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case "media:derive": {
|
|
64
|
+
const data = payload;
|
|
65
|
+
await handlers.derive?.(data.assetId, data.preset, data.format);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "content:scheduledPublish": {
|
|
69
|
+
const data = payload;
|
|
70
|
+
await handlers.publish?.(data.contentId);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "workflow:run": {
|
|
74
|
+
const data = payload;
|
|
75
|
+
await handlers.workflowRun?.(data.runId);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case "maintenance:pruneApiKeys": await db.delete(schema.apikeys).where(sql`${schema.apikeys.expiresAt} is not null and ${schema.apikeys.expiresAt} < now()`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
export { createJobRunner };
|
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manablox/jobs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
|
-
"types": "./
|
|
8
|
-
"default": "./
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
|
-
"main": "./
|
|
12
|
-
"types": "./
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@manablox/core": "0.
|
|
15
|
-
"@manablox/db": "0.
|
|
14
|
+
"@manablox/core": "0.3.0",
|
|
15
|
+
"@manablox/db": "0.3.0",
|
|
16
16
|
"bullmq": "^6.3.4",
|
|
17
17
|
"ioredis": "^6.0.0",
|
|
18
18
|
"drizzle-orm": "^0.45.2"
|
|
@@ -20,9 +20,16 @@
|
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@manablox/config-typescript": "0.0.0",
|
|
22
22
|
"@types/node": "^26.4.1",
|
|
23
|
+
"tsdown": "^0.23.0",
|
|
23
24
|
"typescript": "^7.0.2"
|
|
24
25
|
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"!dist/**/*.map",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
25
31
|
"scripts": {
|
|
32
|
+
"build": "tsdown",
|
|
26
33
|
"typecheck": "tsc --noEmit"
|
|
27
34
|
}
|
|
28
35
|
}
|
package/src/index.ts
DELETED
|
@@ -1,231 +0,0 @@
|
|
|
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
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{ "extends": "@manablox/config-typescript/library.json", "include": ["src"] }
|