@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 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"] }