hybridq 0.1.1

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,584 @@
1
+ import { createDecipheriv, randomBytes, createCipheriv, createHmac, timingSafeEqual } from 'crypto';
2
+
3
+ // src/storage/memory.ts
4
+ var MemoryAdapter = class {
5
+ constructor(opts = {}) {
6
+ this.queues = /* @__PURE__ */ new Map();
7
+ this.cipher = opts.cipher ?? null;
8
+ }
9
+ bucket(queue) {
10
+ let b = this.queues.get(queue);
11
+ if (!b) this.queues.set(queue, b = []);
12
+ return b;
13
+ }
14
+ seal(job) {
15
+ if (!this.cipher) return { ...job };
16
+ const enc = this.cipher.encrypt(JSON.stringify(job.payload));
17
+ return { ...job, payload: null, _enc: enc };
18
+ }
19
+ open(stored) {
20
+ if (!this.cipher || !stored._enc) {
21
+ const { _enc: _enc2, ...job2 } = stored;
22
+ return job2;
23
+ }
24
+ const payload = JSON.parse(this.cipher.decrypt(stored._enc));
25
+ const { _enc, ...job } = stored;
26
+ return { ...job, payload };
27
+ }
28
+ async push(job) {
29
+ this.bucket(job.queue).push(this.seal(job));
30
+ }
31
+ async shiftBatch(queue, count, leaseMs) {
32
+ const now = Date.now();
33
+ const bucket = this.bucket(queue);
34
+ const claimed = [];
35
+ for (const stored of bucket) {
36
+ if (claimed.length >= count) break;
37
+ const eligible = (stored.status === "pending" || stored.status === "delayed") && stored.runAt <= now;
38
+ const lapsed = stored.status === "active" && stored.leaseUntil !== void 0 && stored.leaseUntil <= now;
39
+ if (!eligible && !lapsed) continue;
40
+ stored.status = "active";
41
+ stored.attempts += 1;
42
+ stored.leaseUntil = now + leaseMs;
43
+ claimed.push(this.open(stored));
44
+ }
45
+ return claimed;
46
+ }
47
+ async complete(queue, jobId) {
48
+ const bucket = this.bucket(queue);
49
+ const i = bucket.findIndex((j) => j.id === jobId);
50
+ if (i !== -1) bucket.splice(i, 1);
51
+ }
52
+ async fail(queue, jobId, error, retry, backoffMs) {
53
+ const job = this.bucket(queue).find((j) => j.id === jobId);
54
+ if (!job) return;
55
+ job.lastError = error;
56
+ job.leaseUntil = void 0;
57
+ if (retry && job.attempts < job.maxAttempts) {
58
+ job.status = "pending";
59
+ job.runAt = Date.now() + backoffMs;
60
+ } else {
61
+ job.status = "failed";
62
+ }
63
+ }
64
+ async release(queue, jobId) {
65
+ const job = this.bucket(queue).find((j) => j.id === jobId);
66
+ if (!job || job.status !== "active") return;
67
+ job.status = "pending";
68
+ job.leaseUntil = void 0;
69
+ job.attempts = Math.max(0, job.attempts - 1);
70
+ }
71
+ async size(queue) {
72
+ const now = Date.now();
73
+ return this.bucket(queue).filter(
74
+ (j) => (j.status === "pending" || j.status === "delayed") && j.runAt <= now
75
+ ).length;
76
+ }
77
+ };
78
+
79
+ // src/storage/redis.ts
80
+ var CLAIM_LUA = `
81
+ local pending = KEYS[1]
82
+ local delayed = KEYS[2]
83
+ local active = KEYS[3]
84
+ local jobs = KEYS[4]
85
+ local now = tonumber(ARGV[1])
86
+ local count = tonumber(ARGV[2])
87
+ local lease = tonumber(ARGV[3])
88
+
89
+ local due = redis.call('ZRANGEBYSCORE', delayed, '-inf', now)
90
+ for _, id in ipairs(due) do
91
+ redis.call('RPUSH', pending, id)
92
+ redis.call('ZREM', delayed, id)
93
+ end
94
+
95
+ local lapsed = redis.call('ZRANGEBYSCORE', active, '-inf', now)
96
+ for _, id in ipairs(lapsed) do
97
+ redis.call('RPUSH', pending, id)
98
+ redis.call('ZREM', active, id)
99
+ end
100
+
101
+ local out = {}
102
+ for i = 1, count do
103
+ local id = redis.call('LPOP', pending)
104
+ if not id then break end
105
+ local raw = redis.call('HGET', jobs, id)
106
+ if raw then
107
+ local env = cjson.decode(raw)
108
+ env.status = 'active'
109
+ env.attempts = (env.attempts or 0) + 1
110
+ env.leaseUntil = now + lease
111
+ local enc = cjson.encode(env)
112
+ redis.call('HSET', jobs, id, enc)
113
+ redis.call('ZADD', active, env.leaseUntil, id)
114
+ table.insert(out, enc)
115
+ end
116
+ end
117
+ return out
118
+ `;
119
+ var RedisAdapter = class {
120
+ constructor(redis, opts = {}) {
121
+ this.redis = redis;
122
+ this.ns = opts.namespace ?? "hq";
123
+ this.cipher = opts.cipher ?? null;
124
+ }
125
+ k(queue, suffix) {
126
+ return `${this.ns}:${queue}:${suffix}`;
127
+ }
128
+ toEnvelope(job) {
129
+ const plain = JSON.stringify(job.payload ?? null);
130
+ const enc = !!this.cipher;
131
+ return {
132
+ id: job.id,
133
+ queue: job.queue,
134
+ status: job.status,
135
+ attempts: job.attempts,
136
+ maxAttempts: job.maxAttempts,
137
+ runAt: job.runAt,
138
+ createdAt: job.createdAt,
139
+ leaseUntil: job.leaseUntil,
140
+ lastError: job.lastError,
141
+ payloadJson: enc ? this.cipher.encrypt(plain) : plain,
142
+ enc
143
+ };
144
+ }
145
+ fromEnvelope(env) {
146
+ const plain = env.enc ? this.cipher ? this.cipher.decrypt(env.payloadJson) : (() => {
147
+ throw new Error(
148
+ "hybridq: job is encrypted but no cipher is configured"
149
+ );
150
+ })() : env.payloadJson;
151
+ return {
152
+ id: env.id,
153
+ queue: env.queue,
154
+ payload: JSON.parse(plain),
155
+ status: env.status,
156
+ attempts: env.attempts,
157
+ maxAttempts: env.maxAttempts,
158
+ runAt: env.runAt,
159
+ createdAt: env.createdAt,
160
+ leaseUntil: env.leaseUntil,
161
+ lastError: env.lastError
162
+ };
163
+ }
164
+ async push(job) {
165
+ const env = this.toEnvelope(job);
166
+ const raw = JSON.stringify(env);
167
+ await this.redis.hset(this.k(job.queue, "jobs"), job.id, raw);
168
+ if (job.runAt > Date.now()) {
169
+ await this.redis.zadd(this.k(job.queue, "delayed"), job.runAt, job.id);
170
+ } else {
171
+ await this.redis.rpush(this.k(job.queue, "pending"), job.id);
172
+ }
173
+ }
174
+ async shiftBatch(queue, count, leaseMs) {
175
+ const res = await this.redis.eval(
176
+ CLAIM_LUA,
177
+ [
178
+ this.k(queue, "pending"),
179
+ this.k(queue, "delayed"),
180
+ this.k(queue, "active"),
181
+ this.k(queue, "jobs")
182
+ ],
183
+ [Date.now(), count, leaseMs]
184
+ );
185
+ if (!res || res.length === 0) return [];
186
+ return res.map((raw) => this.fromEnvelope(JSON.parse(raw)));
187
+ }
188
+ async complete(queue, jobId) {
189
+ await this.redis.zrem(this.k(queue, "active"), jobId);
190
+ await this.redis.hdel(this.k(queue, "jobs"), jobId);
191
+ }
192
+ async fail(queue, jobId, error, retry, backoffMs) {
193
+ const raw = await this.redis.hget(this.k(queue, "jobs"), jobId);
194
+ if (!raw) return;
195
+ const env = JSON.parse(raw);
196
+ env.lastError = error;
197
+ env.leaseUntil = void 0;
198
+ await this.redis.zrem(this.k(queue, "active"), jobId);
199
+ if (retry && env.attempts < env.maxAttempts) {
200
+ env.status = "pending";
201
+ env.runAt = Date.now() + backoffMs;
202
+ await this.redis.hset(this.k(queue, "jobs"), jobId, JSON.stringify(env));
203
+ if (backoffMs > 0) {
204
+ await this.redis.zadd(this.k(queue, "delayed"), env.runAt, jobId);
205
+ } else {
206
+ await this.redis.rpush(this.k(queue, "pending"), jobId);
207
+ }
208
+ } else {
209
+ env.status = "failed";
210
+ await this.redis.hset(this.k(queue, "jobs"), jobId, JSON.stringify(env));
211
+ }
212
+ }
213
+ async release(queue, jobId) {
214
+ const raw = await this.redis.hget(this.k(queue, "jobs"), jobId);
215
+ if (!raw) return;
216
+ const env = JSON.parse(raw);
217
+ if (env.status !== "active") return;
218
+ env.status = "pending";
219
+ env.leaseUntil = void 0;
220
+ env.attempts = Math.max(0, env.attempts - 1);
221
+ await this.redis.zrem(this.k(queue, "active"), jobId);
222
+ await this.redis.hset(this.k(queue, "jobs"), jobId, JSON.stringify(env));
223
+ await this.redis.rpush(this.k(queue, "pending"), jobId);
224
+ }
225
+ async size(queue) {
226
+ return this.redis.llen(this.k(queue, "pending"));
227
+ }
228
+ };
229
+ function fromUpstash(client) {
230
+ return {
231
+ eval: (script, keys, args) => client.eval(script, keys, args),
232
+ hget: (k, f) => client.hget(k, f),
233
+ hset: (k, f, v) => client.hset(k, { [f]: v }),
234
+ hdel: (k, f) => client.hdel(k, f),
235
+ rpush: (k, v) => client.rpush(k, v),
236
+ zadd: (k, score, member) => client.zadd(k, { score, member }),
237
+ zrem: (k, m) => client.zrem(k, m),
238
+ llen: (k) => client.llen(k)
239
+ };
240
+ }
241
+ function fromIORedis(client) {
242
+ return {
243
+ eval: (script, keys, args) => client.eval(script, keys.length, ...keys, ...args),
244
+ hget: (k, f) => client.hget(k, f),
245
+ hset: (k, f, v) => client.hset(k, f, v),
246
+ hdel: (k, f) => client.hdel(k, f),
247
+ rpush: (k, v) => client.rpush(k, v),
248
+ zadd: (k, score, member) => client.zadd(k, score, member),
249
+ zrem: (k, m) => client.zrem(k, m),
250
+ llen: (k) => client.llen(k)
251
+ };
252
+ }
253
+ function getSharedIORedis(factory, key = "__hybridq_ioredis__") {
254
+ const g = globalThis;
255
+ if (!g[key]) g[key] = factory();
256
+ return g[key];
257
+ }
258
+
259
+ // src/engine/processor.ts
260
+ var defaultBackoff = (attempt) => Math.min(3e4, 1e3 * 2 ** Math.max(0, attempt - 1));
261
+ function cpuSampler() {
262
+ const proc = globalThis.process;
263
+ if (!proc?.cpuUsage) return () => 0;
264
+ const startCpu = proc.cpuUsage();
265
+ const startWall = Date.now();
266
+ return () => {
267
+ const d = proc.cpuUsage(startCpu);
268
+ const wallUs = Math.max(1, (Date.now() - startWall) * 1e3);
269
+ return (d.user + d.system) / wallUs * 100;
270
+ };
271
+ }
272
+ async function drain(opts) {
273
+ const {
274
+ queue,
275
+ adapter,
276
+ handler,
277
+ budget,
278
+ lock,
279
+ batchSize = 10,
280
+ leaseMs = budget.maxExecutionTimeMs,
281
+ backoff = defaultBackoff,
282
+ onError
283
+ } = opts;
284
+ const startedAt = Date.now();
285
+ const elapsed = () => Date.now() - startedAt;
286
+ const sampleCpu = cpuSampler();
287
+ let processed = 0;
288
+ let failed = 0;
289
+ let released = 0;
290
+ let stoppedBy = "drained";
291
+ const lockKey = `${queue}:drain-lock`;
292
+ let lockToken = null;
293
+ if (lock) {
294
+ lockToken = await lock.acquire(lockKey, budget.maxExecutionTimeMs + 2e3);
295
+ if (!lockToken) {
296
+ return { processed, failed, released, stoppedBy: "lockBusy", elapsedMs: 0 };
297
+ }
298
+ }
299
+ const done = () => processed + failed;
300
+ const budgetExhausted = () => {
301
+ if (done() >= budget.maxJobsPerTrigger) return "jobCap";
302
+ if (elapsed() >= budget.maxExecutionTimeMs) return "timeBudget";
303
+ if (budget.maxCpuBudgetPct !== void 0 && sampleCpu() >= budget.maxCpuBudgetPct) {
304
+ return "cpuBudget";
305
+ }
306
+ return null;
307
+ };
308
+ try {
309
+ outer: while (true) {
310
+ const hit = budgetExhausted();
311
+ if (hit) {
312
+ stoppedBy = hit;
313
+ break;
314
+ }
315
+ const remainingByCount = budget.maxJobsPerTrigger - done();
316
+ const claimCount = Math.max(1, Math.min(batchSize, remainingByCount));
317
+ const batch = await adapter.shiftBatch(queue, claimCount, leaseMs);
318
+ if (batch.length === 0) {
319
+ stoppedBy = "drained";
320
+ break;
321
+ }
322
+ for (let i = 0; i < batch.length; i++) {
323
+ const job = batch[i];
324
+ const hit2 = budgetExhausted();
325
+ if (hit2) {
326
+ for (let j = i; j < batch.length; j++) {
327
+ await adapter.release(queue, batch[j].id);
328
+ released++;
329
+ }
330
+ stoppedBy = hit2;
331
+ break outer;
332
+ }
333
+ try {
334
+ await handler(job);
335
+ await adapter.complete(queue, job.id);
336
+ processed++;
337
+ } catch (err) {
338
+ const message = err instanceof Error ? err.message : String(err);
339
+ onError?.(err, job);
340
+ await adapter.fail(
341
+ queue,
342
+ job.id,
343
+ message,
344
+ /* retry */
345
+ true,
346
+ backoff(job.attempts)
347
+ );
348
+ failed++;
349
+ }
350
+ }
351
+ }
352
+ } finally {
353
+ if (lock && lockToken) await lock.release(lockKey, lockToken);
354
+ }
355
+ return { processed, failed, released, stoppedBy, elapsedMs: elapsed() };
356
+ }
357
+
358
+ // src/util/id.ts
359
+ function newId(prefix = "job") {
360
+ const g = globalThis;
361
+ const uuid = g.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
362
+ return `${prefix}_${uuid}`;
363
+ }
364
+
365
+ // src/engine/lock.ts
366
+ var ACQUIRE_LUA = `return redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])`;
367
+ var RELEASE_LUA = `
368
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
369
+ return redis.call('DEL', KEYS[1])
370
+ end
371
+ return 0`;
372
+ var RedisLock = class {
373
+ constructor(redis) {
374
+ this.redis = redis;
375
+ }
376
+ async acquire(key, ttlMs) {
377
+ const token = newId("lock");
378
+ const res = await this.redis.eval(ACQUIRE_LUA, [key], [token, ttlMs]);
379
+ return res === "OK" || res === true ? token : null;
380
+ }
381
+ async release(key, token) {
382
+ await this.redis.eval(RELEASE_LUA, [key], [token]);
383
+ }
384
+ };
385
+ var MemoryLock = class {
386
+ constructor() {
387
+ this.held = /* @__PURE__ */ new Map();
388
+ }
389
+ async acquire(key, ttlMs) {
390
+ const now = Date.now();
391
+ const cur = this.held.get(key);
392
+ if (cur && cur.until > now) return null;
393
+ const token = newId("lock");
394
+ this.held.set(key, { token, until: now + ttlMs });
395
+ return token;
396
+ }
397
+ async release(key, token) {
398
+ const cur = this.held.get(key);
399
+ if (cur && cur.token === token) this.held.delete(key);
400
+ }
401
+ };
402
+
403
+ // src/server/queue.ts
404
+ var DEFAULT_BUDGET = {
405
+ maxJobsPerTrigger: 25,
406
+ maxExecutionTimeMs: 8e3
407
+ // safe under a 10s serverless limit
408
+ };
409
+ var Queue = class {
410
+ constructor(config) {
411
+ this.config = config;
412
+ this.budget = { ...DEFAULT_BUDGET, ...config.budget };
413
+ }
414
+ get name() {
415
+ return this.config.name;
416
+ }
417
+ /** Producer side: push a unit of work. Returns the created job. */
418
+ async enqueue(payload, opts = {}) {
419
+ const now = Date.now();
420
+ const job = {
421
+ id: opts.id ?? newId("job"),
422
+ queue: this.config.name,
423
+ payload,
424
+ status: opts.delayMs && opts.delayMs > 0 ? "delayed" : "pending",
425
+ attempts: 0,
426
+ maxAttempts: opts.maxAttempts ?? this.config.defaultMaxAttempts ?? 3,
427
+ runAt: now + (opts.delayMs ?? 0),
428
+ createdAt: now
429
+ };
430
+ await this.config.adapter.push(job);
431
+ return job;
432
+ }
433
+ /** How many jobs are currently claimable. Handy for trigger gating. */
434
+ size() {
435
+ return this.config.adapter.size(this.config.name);
436
+ }
437
+ /**
438
+ * Consumer side: drain a budgeted batch with the given handler. Safe to call
439
+ * from many concurrent requests — the lock ensures only one actually runs.
440
+ */
441
+ process(handler, budgetOverride) {
442
+ return drain({
443
+ queue: this.config.name,
444
+ adapter: this.config.adapter,
445
+ handler,
446
+ budget: { ...this.budget, ...budgetOverride },
447
+ lock: this.config.lock,
448
+ batchSize: this.config.batchSize,
449
+ backoff: this.config.backoff
450
+ });
451
+ }
452
+ };
453
+ function defineQueue(config) {
454
+ return new Queue(config);
455
+ }
456
+ var ALGO = "aes-256-gcm";
457
+ var IV_BYTES = 12;
458
+ var ENVELOPE_PREFIX = "hqv1";
459
+ function deriveKey(secret) {
460
+ return createHmac("sha256", "hybridq:payload-key").update(secret).digest();
461
+ }
462
+ function createPayloadCipher(secret) {
463
+ if (!secret) return null;
464
+ const key = deriveKey(secret);
465
+ return {
466
+ encrypt(plaintext) {
467
+ const iv = randomBytes(IV_BYTES);
468
+ const cipher = createCipheriv(ALGO, key, iv);
469
+ const ct = Buffer.concat([
470
+ cipher.update(plaintext, "utf8"),
471
+ cipher.final()
472
+ ]);
473
+ const tag = cipher.getAuthTag();
474
+ return [
475
+ ENVELOPE_PREFIX,
476
+ iv.toString("base64url"),
477
+ tag.toString("base64url"),
478
+ ct.toString("base64url")
479
+ ].join(".");
480
+ },
481
+ decrypt(envelope) {
482
+ const parts = envelope.split(".");
483
+ if (parts.length !== 4 || parts[0] !== ENVELOPE_PREFIX) {
484
+ throw new Error("hybridq: malformed encrypted payload envelope");
485
+ }
486
+ const [, ivB64, tagB64, ctB64] = parts;
487
+ const iv = Buffer.from(ivB64, "base64url");
488
+ const tag = Buffer.from(tagB64, "base64url");
489
+ const ct = Buffer.from(ctB64, "base64url");
490
+ const decipher = createDecipheriv(ALGO, key, iv);
491
+ decipher.setAuthTag(tag);
492
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString(
493
+ "utf8"
494
+ );
495
+ }
496
+ };
497
+ }
498
+ function safeEqual(a, b) {
499
+ const ab = Buffer.from(a);
500
+ const bb = Buffer.from(b);
501
+ if (ab.length !== bb.length) return false;
502
+ return timingSafeEqual(ab, bb);
503
+ }
504
+ function signTrigger(secret, path, ts = Date.now()) {
505
+ const sig = createHmac("sha256", secret).update(`${ts}.${path}`).digest("base64url");
506
+ return `t=${ts},v=${sig}`;
507
+ }
508
+ function verifyTrigger(secret, path, token, maxSkewMs = 5 * 6e4) {
509
+ const m = /^t=(\d+),v=([A-Za-z0-9_-]+)$/.exec(token.trim());
510
+ if (!m) return false;
511
+ const ts = Number(m[1]);
512
+ const sig = m[2];
513
+ if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > maxSkewMs) {
514
+ return false;
515
+ }
516
+ const expected = createHmac("sha256", secret).update(`${ts}.${path}`).digest("base64url");
517
+ return safeEqual(sig, expected);
518
+ }
519
+
520
+ // src/server/middleware.ts
521
+ var TRIGGER_SECRET_HEADER = "x-worker-trigger-secret";
522
+ var TRIGGER_TOKEN_HEADER = "x-worker-trigger-token";
523
+ function authorizeTrigger(req, auth) {
524
+ let configured = false;
525
+ if (auth.secret) {
526
+ configured = true;
527
+ const provided = req.headers.get(TRIGGER_SECRET_HEADER);
528
+ if (provided && safeEqual(provided, auth.secret)) return true;
529
+ }
530
+ if (auth.hmacSecret) {
531
+ configured = true;
532
+ const token = req.headers.get(TRIGGER_TOKEN_HEADER);
533
+ const path = auth.path ?? new URL(req.url).pathname;
534
+ if (token && verifyTrigger(auth.hmacSecret, path, token)) return true;
535
+ }
536
+ return configured ? false : false;
537
+ }
538
+ function deferDrain(run, ctx) {
539
+ const p = run().catch(() => {
540
+ });
541
+ if (ctx?.waitUntil) ctx.waitUntil(p);
542
+ }
543
+ function withTrigger(route, opts) {
544
+ return async (req) => {
545
+ const res = await route(req);
546
+ deferDrain(async () => {
547
+ if (opts.skipIfEmpty && await opts.queue.size() === 0) {
548
+ return {
549
+ processed: 0,
550
+ failed: 0,
551
+ released: 0,
552
+ stoppedBy: "drained",
553
+ elapsedMs: 0
554
+ };
555
+ }
556
+ return opts.queue.process(opts.handler, opts.budget);
557
+ }, opts.ctx);
558
+ return res;
559
+ };
560
+ }
561
+ function createTriggerEndpoint(opts) {
562
+ const background = opts.background ?? true;
563
+ return async (req) => {
564
+ if (!authorizeTrigger(req, opts.auth)) {
565
+ return new Response("Forbidden", { status: 403 });
566
+ }
567
+ if (background) {
568
+ deferDrain(() => opts.queue.process(opts.handler, opts.budget), opts.ctx);
569
+ return new Response(JSON.stringify({ accepted: true }), {
570
+ status: 202,
571
+ headers: { "content-type": "application/json" }
572
+ });
573
+ }
574
+ const report = await opts.queue.process(opts.handler, opts.budget);
575
+ return new Response(JSON.stringify(report), {
576
+ status: 200,
577
+ headers: { "content-type": "application/json" }
578
+ });
579
+ };
580
+ }
581
+
582
+ export { MemoryAdapter, MemoryLock, Queue, RedisAdapter, RedisLock, TRIGGER_SECRET_HEADER, TRIGGER_TOKEN_HEADER, authorizeTrigger, createPayloadCipher, createTriggerEndpoint, defineQueue, drain, fromIORedis, fromUpstash, getSharedIORedis, newId, signTrigger, verifyTrigger, withTrigger };
583
+ //# sourceMappingURL=index.js.map
584
+ //# sourceMappingURL=index.js.map