bunqueue 2.8.25 → 2.8.26
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/dist/application/operations/jobManagement.js +5 -0
- package/dist/application/operations/jobStateTransitions.js +3 -0
- package/dist/application/operations/push.js +45 -7
- package/dist/application/queueManager.js +9 -1
- package/dist/client/jobConversionHelpers.js +5 -1
- package/dist/client/queue/jobMove.js +13 -1
- package/dist/client/queue/operations/add.js +6 -1
- package/dist/client/queue/workers.js +4 -2
- package/dist/client/workflow/executor.js +14 -1
- package/dist/infrastructure/persistence/sqlite.d.ts +9 -0
- package/dist/infrastructure/persistence/sqlite.js +21 -0
- package/dist/infrastructure/persistence/sqliteBatch.js +19 -1
- package/dist/infrastructure/persistence/statements.js +22 -0
- package/dist/shared/webhookValidation.js +70 -0
- package/package.json +1 -1
|
@@ -206,6 +206,11 @@ export async function moveJobToDelayed(jobId, delay, ctx) {
|
|
|
206
206
|
shard.incrementQueued(jobId, isDelayed, job.createdAt, job.queue, job.runAt);
|
|
207
207
|
ctx.jobIndex.set(jobId, { type: 'queue', shardIdx: idx, queueName: job.queue });
|
|
208
208
|
});
|
|
209
|
+
// Persist the move so the delay survives a restart: the on-disk row was
|
|
210
|
+
// `state='active'` with the old run_at, which recovery would otherwise treat as a
|
|
211
|
+
// stalled active job. updateRunAt re-derives 'delayed' from the future run_at and
|
|
212
|
+
// clears started_at.
|
|
213
|
+
ctx.storage?.updateRunAt(jobId, job.runAt);
|
|
209
214
|
// Emit delayed event (BullMQ v5)
|
|
210
215
|
ctx.eventsManager.broadcast({
|
|
211
216
|
eventType: "delayed" /* EventType.Delayed */,
|
|
@@ -57,6 +57,9 @@ export async function changeWaitingDelay(jobId, delay, ctx) {
|
|
|
57
57
|
if (!job)
|
|
58
58
|
return false;
|
|
59
59
|
q.updateRunAt(jobId, newRunAt);
|
|
60
|
+
// Persist so the delay survives a restart (otherwise recovery reloads the
|
|
61
|
+
// stale on-disk run_at and the job is immediately pullable again).
|
|
62
|
+
ctx.storage?.updateRunAt(jobId, newRunAt);
|
|
60
63
|
return true;
|
|
61
64
|
});
|
|
62
65
|
}
|
|
@@ -17,12 +17,32 @@ function handleCustomId(input, shard, ctx) {
|
|
|
17
17
|
}
|
|
18
18
|
const id = jobId(input.customId);
|
|
19
19
|
const existing = ctx.customIdMap.get(input.customId);
|
|
20
|
-
//
|
|
21
|
-
|
|
20
|
+
// Re-adding an existing custom id while its job is UNFINISHED is an idempotent
|
|
21
|
+
// no-op (BullMQ parity): return the existing job, never a second insert of the
|
|
22
|
+
// same deterministic id (which would collide on the jobs.id PRIMARY KEY). Three
|
|
23
|
+
// unfinished states are covered:
|
|
24
|
+
// • waiting/delayed/prioritized — live in the priority queue → return the job.
|
|
25
|
+
// • waiting-children — tracked under 'queue' but held in shard.waitingDeps (its
|
|
26
|
+
// row is already persisted) → idempotent skip via the existing id.
|
|
27
|
+
// • active (processing) — popped from the queue, still running on a worker; its
|
|
28
|
+
// row survives on disk → idempotent skip via the existing id.
|
|
29
|
+
// PushContext has no processingShards, so for the latter two we cannot fetch the
|
|
30
|
+
// live Job here; pushJob rebuilds a placeholder { ...job, id } after createJob,
|
|
31
|
+
// exactly like handleDeduplication's active path. Completed (#92) and DLQ jobs
|
|
32
|
+
// are terminal and fall through to the reuse path below.
|
|
33
|
+
if (existing && !ctx.completedJobs.has(id)) {
|
|
22
34
|
const location = ctx.jobIndex.get(existing);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
if (location?.type === 'queue') {
|
|
36
|
+
const existingJob = shard.getQueue(location.queueName).find(existing);
|
|
37
|
+
if (existingJob) {
|
|
38
|
+
return { skip: true, existingJob };
|
|
39
|
+
}
|
|
40
|
+
if (shard.waitingDeps.has(existing)) {
|
|
41
|
+
return { skip: true, existingId: id };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if (location?.type === 'processing') {
|
|
45
|
+
return { skip: true, existingId: id };
|
|
26
46
|
}
|
|
27
47
|
}
|
|
28
48
|
// Reuse path: the id is free in the queue (no mapping, or the prior job is
|
|
@@ -40,6 +60,13 @@ function handleCustomId(input, shard, ctx) {
|
|
|
40
60
|
ctx.jobIndex.delete(id);
|
|
41
61
|
ctx.storage?.deleteJob(id); // removes the surviving row + result + any buffered insert
|
|
42
62
|
}
|
|
63
|
+
// NOTE on ORPHAN rows: a durable jobs row can outlive its in-memory tracking when
|
|
64
|
+
// obliterate() (fire-and-forget void over TCP) or a buffer flush races an in-flight
|
|
65
|
+
// durable insert. Reaching the reuse path with such a stale row no longer throws —
|
|
66
|
+
// the insert statements use ON CONFLICT(id) DO UPDATE (upsert), so the orphan is
|
|
67
|
+
// overwritten in place at insert time with ZERO per-push cost. We deliberately do
|
|
68
|
+
// NOT pre-delete here: deleteJob() is a synchronous DELETE + O(buffer) scan inside
|
|
69
|
+
// the shard lock and would halve customId push/addBulk throughput.
|
|
43
70
|
// A recycled custom id may carry a stale timeout marker from a prior job (which
|
|
44
71
|
// may have DLQ'd, so it is NOT in completedJobs above). Clear it so the new
|
|
45
72
|
// job's stall-retry recovery is not wrongly discarded — otherwise the
|
|
@@ -158,7 +185,15 @@ export async function pushJob(queue, input, ctx) {
|
|
|
158
185
|
// Check custom ID idempotency INSIDE lock to prevent race conditions
|
|
159
186
|
const customIdResult = handleCustomId(input, shard, ctx);
|
|
160
187
|
if (customIdResult.skip) {
|
|
161
|
-
|
|
188
|
+
// Idempotent re-add: return the live queued job if we have it, otherwise
|
|
189
|
+
// (active / waiting-children) a placeholder carrying the existing id so the
|
|
190
|
+
// caller sees the right id without inserting a duplicate row.
|
|
191
|
+
result = {
|
|
192
|
+
job: 'existingJob' in customIdResult
|
|
193
|
+
? customIdResult.existingJob
|
|
194
|
+
: createJob(customIdResult.existingId, queue, input, now),
|
|
195
|
+
persisted: false,
|
|
196
|
+
};
|
|
162
197
|
return;
|
|
163
198
|
}
|
|
164
199
|
const job = createJob(customIdResult.id, queue, input, now);
|
|
@@ -218,7 +253,10 @@ export async function pushJobBatch(queue, inputs, ctx) {
|
|
|
218
253
|
// Check custom ID idempotency
|
|
219
254
|
const customIdResult = handleCustomId(input, shard, ctx);
|
|
220
255
|
if (customIdResult.skip) {
|
|
221
|
-
|
|
256
|
+
// Idempotent re-add (queued, active, or waiting-children) — no insert.
|
|
257
|
+
resultIds.push('existingJob' in customIdResult
|
|
258
|
+
? customIdResult.existingJob.id
|
|
259
|
+
: customIdResult.existingId);
|
|
222
260
|
continue;
|
|
223
261
|
}
|
|
224
262
|
const job = createJob(customIdResult.id, queue, input, now);
|
|
@@ -978,7 +978,15 @@ export class QueueManager {
|
|
|
978
978
|
return jobMgmt.promoteJob(jobId, this.contextFactory.getJobMgmtContext());
|
|
979
979
|
}
|
|
980
980
|
async moveToDelayed(jobId, delay) {
|
|
981
|
-
|
|
981
|
+
// moveToDelayed and changeDelay share identical routing in this engine: a job
|
|
982
|
+
// already in the queue (waiting/prioritized/delayed) gets its runAt updated in
|
|
983
|
+
// place so it becomes (or stays) delayed; an active/processing job is moved back
|
|
984
|
+
// to the queue with the new delay. Delegate to changeDelay so the in-queue case
|
|
985
|
+
// is handled — previously moveJobToDelayed only handled `processing` jobs, making
|
|
986
|
+
// a WAITING job a silent no-op over TCP/HTTP/MCP. Mirrors the embedded client's
|
|
987
|
+
// moveJobToDelayed branching (changeWaitingDelay for in-queue, moveJobToDelayed
|
|
988
|
+
// for active). See src/client/queue/jobMove.ts.
|
|
989
|
+
return this.changeDelay(jobId, delay);
|
|
982
990
|
}
|
|
983
991
|
async changeDelay(jobId, delay) {
|
|
984
992
|
const ctx = this.contextFactory.getJobMgmtContext();
|
|
@@ -28,7 +28,11 @@ export function buildJobProperties(job, name, stacktrace, token, processedBy) {
|
|
|
28
28
|
opts: jobOpts,
|
|
29
29
|
token,
|
|
30
30
|
processedBy,
|
|
31
|
-
deduplicationId
|
|
31
|
+
// deduplicationId reflects the requested jobId OR deduplication.id. customId
|
|
32
|
+
// now holds ONLY an explicit jobId (the dedup id rides on uniqueKey since the
|
|
33
|
+
// dedup-replace fix), so fall back to uniqueKey — mirrors the TCP proxy's
|
|
34
|
+
// `opts.jobId ?? opts.deduplication?.id` (#90).
|
|
35
|
+
deduplicationId: job.customId ?? job.uniqueKey ?? undefined,
|
|
32
36
|
repeatJobKey: buildRepeatJobKey(job),
|
|
33
37
|
attemptsStarted: job.attempts,
|
|
34
38
|
};
|
|
@@ -78,7 +78,19 @@ export async function moveJobToDelayed(ctx, id, timestamp, _token) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
else {
|
|
81
|
-
|
|
81
|
+
// The public API takes an ABSOLUTE timestamp, but the server works in relative
|
|
82
|
+
// delay (runAt = now + delay) and both MoveToDelayedCommand and
|
|
83
|
+
// handleMoveToDelayed read the wire field `delay`. Sending `timestamp` left
|
|
84
|
+
// `delay` undefined server-side → runAt = now + undefined = NaN (an active job
|
|
85
|
+
// was re-queued as `waiting` with the delay dropped) or a silent no-op (a
|
|
86
|
+
// waiting job). Convert to relative delay here so the wire field matches.
|
|
87
|
+
const delay = Math.max(0, timestamp - Date.now());
|
|
88
|
+
const response = await ctx.tcp.send({ cmd: 'MoveToDelayed', id, delay });
|
|
89
|
+
// Surface server-side failures instead of silently ignoring the response
|
|
90
|
+
// (e.g. the job was completed/removed before the command arrived).
|
|
91
|
+
if (response.ok === false) {
|
|
92
|
+
throw new Error(typeof response.error === 'string' ? response.error : 'Failed to move job to delayed');
|
|
93
|
+
}
|
|
82
94
|
}
|
|
83
95
|
}
|
|
84
96
|
/** Move job to waiting-children state */
|
|
@@ -44,7 +44,12 @@ export async function add(ctx, jobName, data, opts = {}) {
|
|
|
44
44
|
ttl: merged.ttl,
|
|
45
45
|
timeout: merged.timeout,
|
|
46
46
|
uniqueKey: merged.deduplication?.id,
|
|
47
|
-
|
|
47
|
+
// Only an explicit jobId becomes the customId. The dedup id is carried by
|
|
48
|
+
// `uniqueKey` above into handleDeduplication (suppress/replace/extend). If we
|
|
49
|
+
// also mirrored it into customId, handleCustomId would short-circuit the re-add
|
|
50
|
+
// as an idempotent no-op BEFORE replace/extend run — silently dropping them in
|
|
51
|
+
// embedded mode. TCP/bulk already use `customId: jobId` only; this matches them.
|
|
52
|
+
customId: merged.jobId,
|
|
48
53
|
dependsOn: merged.dependsOn?.map((id) => jobId(id)),
|
|
49
54
|
tags: merged.tags,
|
|
50
55
|
groupId: merged.groupId,
|
|
@@ -28,8 +28,10 @@ export async function getMetrics(ctx, type, _start, _end) {
|
|
|
28
28
|
const response = await ctx.tcp.send({ cmd: 'Metrics' });
|
|
29
29
|
if (!response.ok)
|
|
30
30
|
return { meta: { count: 0 }, data: [] };
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// The Metrics handler replies with { ok, metrics: { totalCompleted, totalFailed, ... } }
|
|
32
|
+
// (resp.metrics). Reading a non-existent `response.stats` always yielded 0.
|
|
33
|
+
const metrics = response.metrics;
|
|
34
|
+
const count = type === 'completed' ? (metrics?.totalCompleted ?? 0) : (metrics?.totalFailed ?? 0);
|
|
33
35
|
return { meta: { count }, data: [] };
|
|
34
36
|
}
|
|
35
37
|
/** Trim events (no-op in bunqueue - events are real-time) */
|
|
@@ -89,10 +89,23 @@ export class WorkflowExecutor {
|
|
|
89
89
|
clearTimeout(timer);
|
|
90
90
|
this.timeoutTimers.delete(executionId);
|
|
91
91
|
}
|
|
92
|
+
// Always record the payload (idempotent) and notify listeners. A signal that
|
|
93
|
+
// lands before the run parks at its waitFor is still consumed there, via the
|
|
94
|
+
// `signals[event] !== undefined` gate in runWaitFor.
|
|
92
95
|
exec.signals[event] = payload;
|
|
96
|
+
this.emitter?.emitSignal('signal:received', exec.id, exec.workflowName, event, payload);
|
|
97
|
+
// Resume only a genuinely-parked run (state 'waiting'), and only once. The state
|
|
98
|
+
// check and the flip to 'running' are synchronous — no `await` between them — so a
|
|
99
|
+
// second concurrent/duplicate signal() (which can only run after the first yields
|
|
100
|
+
// at `await this.enqueue`, by which point `store.update` has persisted 'running')
|
|
101
|
+
// observes 'running' and returns early. This collapses duplicate/concurrent
|
|
102
|
+
// signals to a single resume, so every step after the waitFor runs exactly once.
|
|
103
|
+
if (exec.state !== 'waiting') {
|
|
104
|
+
this.store.update(exec);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
93
107
|
exec.state = 'running';
|
|
94
108
|
this.store.update(exec);
|
|
95
|
-
this.emitter?.emitSignal('signal:received', exec.id, exec.workflowName, event, payload);
|
|
96
109
|
await this.enqueue(exec);
|
|
97
110
|
}
|
|
98
111
|
getExecution(id) {
|
|
@@ -107,6 +107,15 @@ export declare class SqliteStorage {
|
|
|
107
107
|
deleteJob(jobId: JobId): void;
|
|
108
108
|
/** Update a job's data blob (e.g. after adding __parentId) */
|
|
109
109
|
updateJobData(jobId: JobId, data: unknown): void;
|
|
110
|
+
/**
|
|
111
|
+
* Persist a delay change (moveToDelayed / changeDelay) so the new `run_at`
|
|
112
|
+
* survives a restart. Without this the delay lives only in the in-memory heap:
|
|
113
|
+
* recovery reloads the stale on-disk `run_at` and the job is immediately
|
|
114
|
+
* pullable again. State is re-derived from `run_at` (future → 'delayed'),
|
|
115
|
+
* mirroring the insert convention; `started_at` is cleared because a job moved
|
|
116
|
+
* back to the queue from `active` is no longer running.
|
|
117
|
+
*/
|
|
118
|
+
updateRunAt(jobId: JobId, runAt: number): void;
|
|
110
119
|
/** Update a job's children_ids blob and parent_id */
|
|
111
120
|
updateJobChildrenIds(jobId: JobId, childrenIds: JobId[]): void;
|
|
112
121
|
getJob(id: JobId): Job | null;
|
|
@@ -364,6 +364,27 @@ export class SqliteStorage {
|
|
|
364
364
|
this.db.prepare('UPDATE jobs SET data = ? WHERE id = ?').run(pack(data), jobId);
|
|
365
365
|
});
|
|
366
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Persist a delay change (moveToDelayed / changeDelay) so the new `run_at`
|
|
369
|
+
* survives a restart. Without this the delay lives only in the in-memory heap:
|
|
370
|
+
* recovery reloads the stale on-disk `run_at` and the job is immediately
|
|
371
|
+
* pullable again. State is re-derived from `run_at` (future → 'delayed'),
|
|
372
|
+
* mirroring the insert convention; `started_at` is cleared because a job moved
|
|
373
|
+
* back to the queue from `active` is no longer running.
|
|
374
|
+
*/
|
|
375
|
+
updateRunAt(jobId, runAt) {
|
|
376
|
+
// Flush a still-buffered insert first (like markActive/markCompleted/markFailed)
|
|
377
|
+
// so the UPDATE lands on a real row — otherwise the delay would be lost across a
|
|
378
|
+
// restart when the job was added (non-durable) and re-delayed within the same
|
|
379
|
+
// ~10ms write-buffer window.
|
|
380
|
+
this.flushIfBuffered(jobId);
|
|
381
|
+
this.safeWrite(() => {
|
|
382
|
+
const state = runAt > Date.now() ? 'delayed' : 'waiting';
|
|
383
|
+
this.db
|
|
384
|
+
.prepare('UPDATE jobs SET run_at = ?, state = ?, started_at = NULL WHERE id = ?')
|
|
385
|
+
.run(runAt, state, jobId);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
367
388
|
/** Update a job's children_ids blob and parent_id */
|
|
368
389
|
updateJobChildrenIds(jobId, childrenIds) {
|
|
369
390
|
this.safeWrite(() => {
|
|
@@ -77,11 +77,29 @@ export class BatchInsertManager {
|
|
|
77
77
|
if (!stmt) {
|
|
78
78
|
const rowPlaceholder = '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
|
79
79
|
const placeholders = Array(size).fill(rowPlaceholder).join(', ');
|
|
80
|
+
// ON CONFLICT(id) DO UPDATE (upsert): a colliding id overwrites in place instead
|
|
81
|
+
// of failing the whole flush. Without it, one stale ORPHAN row (left when
|
|
82
|
+
// obliterate()/a flush raced an in-flight insert) makes the batch throw UNIQUE,
|
|
83
|
+
// and reportLostJobs() then drops EVERY innocent job batched in the same flush
|
|
84
|
+
// window. Per-row conflict resolution isolates the collision; new ids stay a
|
|
85
|
+
// plain INSERT (zero extra cost). jobs has no triggers/FKs → no cascade.
|
|
80
86
|
const sql = `INSERT INTO jobs (
|
|
81
87
|
id, queue, data, priority, created_at, run_at, attempts, max_attempts,
|
|
82
88
|
backoff, ttl, timeout, unique_key, custom_id, depends_on, parent_id,
|
|
83
89
|
children_ids, tags, state, lifo, group_id, remove_on_complete, remove_on_fail, stall_timeout, timeline
|
|
84
|
-
) VALUES ${placeholders}
|
|
90
|
+
) VALUES ${placeholders}
|
|
91
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
92
|
+
queue=excluded.queue, data=excluded.data, priority=excluded.priority,
|
|
93
|
+
created_at=excluded.created_at, run_at=excluded.run_at, attempts=excluded.attempts,
|
|
94
|
+
max_attempts=excluded.max_attempts, backoff=excluded.backoff, ttl=excluded.ttl,
|
|
95
|
+
timeout=excluded.timeout, unique_key=excluded.unique_key, custom_id=excluded.custom_id,
|
|
96
|
+
depends_on=excluded.depends_on, parent_id=excluded.parent_id, children_ids=excluded.children_ids,
|
|
97
|
+
tags=excluded.tags, state=excluded.state, lifo=excluded.lifo, group_id=excluded.group_id,
|
|
98
|
+
remove_on_complete=excluded.remove_on_complete, remove_on_fail=excluded.remove_on_fail,
|
|
99
|
+
stall_timeout=excluded.stall_timeout, timeline=excluded.timeline,
|
|
100
|
+
started_at=excluded.started_at, completed_at=excluded.completed_at,
|
|
101
|
+
progress=excluded.progress, progress_msg=excluded.progress_msg,
|
|
102
|
+
last_heartbeat=excluded.last_heartbeat, stacktrace=excluded.stacktrace`;
|
|
85
103
|
stmt = this.db.prepare(sql);
|
|
86
104
|
// Cache statements for common batch sizes (1-100)
|
|
87
105
|
if (size <= 100) {
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
/** SQL statements */
|
|
6
6
|
export const SQL_STATEMENTS = {
|
|
7
|
+
// ON CONFLICT(id) DO UPDATE (upsert): a brand-new id is a plain INSERT (zero extra
|
|
8
|
+
// cost); a colliding id overwrites the existing row in place. This makes durable
|
|
9
|
+
// inserts idempotent against ORPHAN rows — a jobs row that outlived its in-memory
|
|
10
|
+
// tracking when obliterate() or a buffer flush raced an in-flight insert — without
|
|
11
|
+
// any per-push delete on the hot path. jobs has no triggers/FKs, so DO UPDATE has
|
|
12
|
+
// no cascade side effects (unlike REPLACE = DELETE+INSERT).
|
|
7
13
|
insertJob: `
|
|
8
14
|
INSERT INTO jobs (
|
|
9
15
|
id, queue, data, priority, created_at, run_at, attempts,
|
|
@@ -16,6 +22,22 @@ export const SQL_STATEMENTS = {
|
|
|
16
22
|
?, ?, ?, ?, ?, ?, ?,
|
|
17
23
|
?, ?, ?, ?
|
|
18
24
|
)
|
|
25
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
26
|
+
queue=excluded.queue, data=excluded.data, priority=excluded.priority,
|
|
27
|
+
created_at=excluded.created_at, run_at=excluded.run_at, attempts=excluded.attempts,
|
|
28
|
+
max_attempts=excluded.max_attempts, backoff=excluded.backoff, ttl=excluded.ttl,
|
|
29
|
+
timeout=excluded.timeout, unique_key=excluded.unique_key, custom_id=excluded.custom_id,
|
|
30
|
+
depends_on=excluded.depends_on, parent_id=excluded.parent_id, children_ids=excluded.children_ids,
|
|
31
|
+
tags=excluded.tags, state=excluded.state, lifo=excluded.lifo, group_id=excluded.group_id,
|
|
32
|
+
remove_on_complete=excluded.remove_on_complete, remove_on_fail=excluded.remove_on_fail,
|
|
33
|
+
stall_timeout=excluded.stall_timeout, timeline=excluded.timeline,
|
|
34
|
+
-- Per-execution columns are NOT in the INSERT column list, so excluded.<col>
|
|
35
|
+
-- resolves to each column's DEFAULT (NULL / 0) — i.e. the fresh-job value. Reset
|
|
36
|
+
-- them too, otherwise an upsert over an ORPHAN row would leave a brand-new job
|
|
37
|
+
-- carrying the prior life's progress=100 / completed_at / stacktrace etc.
|
|
38
|
+
started_at=excluded.started_at, completed_at=excluded.completed_at,
|
|
39
|
+
progress=excluded.progress, progress_msg=excluded.progress_msg,
|
|
40
|
+
last_heartbeat=excluded.last_heartbeat, stacktrace=excluded.stacktrace
|
|
19
41
|
`,
|
|
20
42
|
updateJobState: 'UPDATE jobs SET state = ?, started_at = ?, timeline = ? WHERE id = ?',
|
|
21
43
|
completeJob: 'UPDATE jobs SET state = ?, completed_at = ?, progress = 100, timeline = ? WHERE id = ?',
|
|
@@ -30,6 +30,63 @@ function checkPrivateIpv4(hostname) {
|
|
|
30
30
|
return 'Webhook URL cannot point to loopback IP';
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Extract the embedded IPv4 from an IPv4-mapped IPv6 hostname, as dotted-decimal,
|
|
35
|
+
* or null if the hostname is not such a mapped address.
|
|
36
|
+
*
|
|
37
|
+
* The WHATWG URL parser normalizes `[::ffff:127.0.0.1]` to the hex form
|
|
38
|
+
* `[::ffff:7f00:1]`, so both must be handled: the dotted tail (`::ffff:a.b.c.d`)
|
|
39
|
+
* and the two-hextet tail (`::ffff:XXXX:YYYY`). These resolve to the embedded IPv4
|
|
40
|
+
* (e.g. 127.0.0.1) and so must run through the private/loopback octet check (SSRF).
|
|
41
|
+
*/
|
|
42
|
+
function extractMappedIpv4(hostname) {
|
|
43
|
+
let h = hostname;
|
|
44
|
+
if (h.startsWith('[') && h.endsWith(']'))
|
|
45
|
+
h = h.slice(1, -1);
|
|
46
|
+
const lower = h.toLowerCase();
|
|
47
|
+
// IPv4-mapped (::ffff:a.b.c.d) AND the deprecated IPv4-compatible (::a.b.c.d) form
|
|
48
|
+
// both embed an IPv4 in the low 32 bits — and the WHATWG parser normalizes the
|
|
49
|
+
// dotted tail to two hextets (::ffff:7f00:1 / ::7f00:1). Unwrap either prefix.
|
|
50
|
+
let tail;
|
|
51
|
+
if (lower.startsWith('::ffff:'))
|
|
52
|
+
tail = h.slice('::ffff:'.length);
|
|
53
|
+
else if (lower.startsWith('::') && lower.length > 2)
|
|
54
|
+
tail = h.slice('::'.length);
|
|
55
|
+
else
|
|
56
|
+
return null;
|
|
57
|
+
// Dotted form: a.b.c.d
|
|
58
|
+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail))
|
|
59
|
+
return tail;
|
|
60
|
+
// Hex-normalized form: XXXX:YYYY (each hextet 1-4 hex digits) → low 32 bits
|
|
61
|
+
const hx = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
|
62
|
+
if (hx) {
|
|
63
|
+
const hi = Number.parseInt(hx[1], 16);
|
|
64
|
+
const lo = Number.parseInt(hx[2], 16);
|
|
65
|
+
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Block IPv6 private / link-local / unspecified ranges that resolve to the local
|
|
71
|
+
* network: ULA `fc00::/7` (first hextet fc__/fd__), link-local `fe80::/10`
|
|
72
|
+
* (fe80–febf), and `::` (unspecified). Returns an error message or null.
|
|
73
|
+
*/
|
|
74
|
+
function checkBlockedIpv6(hostname) {
|
|
75
|
+
let h = hostname;
|
|
76
|
+
if (h.startsWith('[') && h.endsWith(']'))
|
|
77
|
+
h = h.slice(1, -1);
|
|
78
|
+
const lower = h.toLowerCase();
|
|
79
|
+
if (!lower.includes(':'))
|
|
80
|
+
return null; // not an IPv6 literal
|
|
81
|
+
if (lower === '::')
|
|
82
|
+
return 'Webhook URL cannot point to unspecified IP';
|
|
83
|
+
const first = lower.split(':')[0];
|
|
84
|
+
if (/^f[cd][0-9a-f]{0,2}$/.test(first))
|
|
85
|
+
return 'Webhook URL cannot point to private (ULA) IP';
|
|
86
|
+
if (/^fe[89ab][0-9a-f]?$/.test(first))
|
|
87
|
+
return 'Webhook URL cannot point to link-local IP';
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
33
90
|
/** Check if hostname is a cloud metadata endpoint */
|
|
34
91
|
function isCloudMetadata(hostname) {
|
|
35
92
|
return (hostname === '169.254.169.254' ||
|
|
@@ -55,6 +112,19 @@ export function validateWebhookUrl(url) {
|
|
|
55
112
|
const hostname = parsed.hostname.toLowerCase();
|
|
56
113
|
if (isLocalhost(hostname))
|
|
57
114
|
return 'Webhook URL cannot point to localhost';
|
|
115
|
+
// IPv4-mapped / IPv4-compatible IPv6 (e.g. [::ffff:127.0.0.1] → [::ffff:7f00:1],
|
|
116
|
+
// or [::127.0.0.1] → [::7f00:1]) resolves to the embedded IPv4 — unwrap it so
|
|
117
|
+
// loopback/private octets are blocked (SSRF).
|
|
118
|
+
const mapped = extractMappedIpv4(hostname);
|
|
119
|
+
if (mapped) {
|
|
120
|
+
const mappedError = checkPrivateIpv4(mapped);
|
|
121
|
+
if (mappedError)
|
|
122
|
+
return mappedError;
|
|
123
|
+
}
|
|
124
|
+
// IPv6 private (ULA) / link-local / unspecified ranges.
|
|
125
|
+
const ipv6Error = checkBlockedIpv6(hostname);
|
|
126
|
+
if (ipv6Error)
|
|
127
|
+
return ipv6Error;
|
|
58
128
|
const ipError = checkPrivateIpv4(hostname);
|
|
59
129
|
if (ipError)
|
|
60
130
|
return ipError;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunqueue",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.26",
|
|
4
4
|
"description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/main.js",
|