lopata 0.2.1 → 0.3.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.
- package/dist/dashboard/{chunk-jrv1mhg9.css → chunk-paesqsyf.css} +33 -17
- package/dist/dashboard/{chunk-5j5em7qa.js → chunk-ymq225fp.js} +190 -98
- package/dist/dashboard/index.html +1 -1
- package/package.json +13 -16
- package/src/api/dispatch.ts +2 -0
- package/src/api/generate-sql.ts +51 -0
- package/src/api/handlers/d1.ts +8 -0
- package/src/api/handlers/do.ts +8 -0
- package/src/api/handlers/email.ts +1 -1
- package/src/api/handlers/queue.ts +1 -1
- package/src/api/handlers/warnings.ts +8 -0
- package/src/api/index.ts +6 -1
- package/src/api/r2.ts +1 -1
- package/src/api/types.ts +2 -0
- package/src/bindings/browser.ts +0 -3
- package/src/bindings/cache.ts +1 -1
- package/src/bindings/container.ts +4 -4
- package/src/bindings/crypto-extras.ts +1 -1
- package/src/bindings/do-executor-inprocess.ts +4 -0
- package/src/bindings/do-executor-worker.ts +4 -0
- package/src/bindings/do-executor.ts +3 -0
- package/src/bindings/durable-object.ts +32 -2
- package/src/bindings/html-rewriter.ts +26 -1
- package/src/bindings/images.ts +237 -32
- package/src/bindings/queue.ts +17 -1
- package/src/bindings/scheduled.ts +141 -22
- package/src/bindings/service-binding.ts +10 -5
- package/src/bindings/static-assets.ts +13 -1
- package/src/bindings/workflow.ts +5 -2
- package/src/cli/dev.ts +2 -1
- package/src/cli.ts +0 -0
- package/src/config.ts +17 -3
- package/src/dashboard-serve.ts +1 -1
- package/src/db.ts +6 -0
- package/src/env.ts +40 -2
- package/src/execution-context.ts +5 -0
- package/src/generation-manager.ts +7 -3
- package/src/generation.ts +31 -2
- package/src/lopata-config.ts +7 -0
- package/src/plugin.ts +77 -4
- package/src/request-cf.ts +2 -0
- package/src/tracing/store.ts +1 -1
- package/src/tsconfig.json +1 -0
- package/src/vite-plugin/dev-server-plugin.ts +0 -1
- package/src/warnings.ts +30 -0
package/src/bindings/images.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Images binding — Sharp-based implementation for local dev
|
|
2
2
|
// Supports resize, rotate, format conversion, quality, draw overlays, and AVIF dimensions.
|
|
3
3
|
|
|
4
|
-
import
|
|
4
|
+
import type SharpNs from 'sharp'
|
|
5
5
|
|
|
6
6
|
type ImageFormat = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' | 'image/avif' | 'image/svg+xml'
|
|
7
7
|
|
|
@@ -16,15 +16,22 @@ export interface ImageTransformOptions {
|
|
|
16
16
|
width?: number
|
|
17
17
|
height?: number
|
|
18
18
|
fit?: 'contain' | 'cover' | 'crop' | 'scale-down' | 'pad'
|
|
19
|
+
gravity?: 'auto' | 'left' | 'right' | 'top' | 'bottom' | 'center' | { x: number; y: number }
|
|
19
20
|
rotate?: 0 | 90 | 180 | 270
|
|
20
21
|
blur?: number
|
|
21
22
|
brightness?: number
|
|
22
23
|
contrast?: number
|
|
24
|
+
gamma?: number
|
|
25
|
+
saturation?: number
|
|
23
26
|
sharpen?: number
|
|
24
|
-
trim?:
|
|
27
|
+
trim?: number | boolean
|
|
25
28
|
flip?: boolean
|
|
26
29
|
flop?: boolean
|
|
27
30
|
background?: string
|
|
31
|
+
dpr?: number
|
|
32
|
+
border?: { color?: string; width?: number; top?: number; right?: number; bottom?: number; left?: number }
|
|
33
|
+
format?: 'avif' | 'webp' | 'jpeg' | 'png' | 'gif' | 'auto'
|
|
34
|
+
quality?: number | 'high' | 'medium-high' | 'medium-low' | 'low'
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
export interface DrawOptions {
|
|
@@ -37,8 +44,10 @@ export interface DrawOptions {
|
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export interface OutputOptions {
|
|
40
|
-
format: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/avif'
|
|
41
|
-
quality?: number
|
|
47
|
+
format: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/avif' | 'image/gif'
|
|
48
|
+
quality?: number | 'high' | 'medium-high' | 'medium-low' | 'low'
|
|
49
|
+
compression?: 'fast'
|
|
50
|
+
metadata?: 'keep' | 'copyright' | 'none'
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
export interface ImageOutputResult {
|
|
@@ -46,6 +55,47 @@ export interface ImageOutputResult {
|
|
|
46
55
|
contentType(): string
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
// --- Lazy sharp loading ---
|
|
59
|
+
|
|
60
|
+
let _sharpResolved = false
|
|
61
|
+
let _sharp: typeof SharpNs | null = null
|
|
62
|
+
let _sharpWarned = false
|
|
63
|
+
|
|
64
|
+
async function getSharp(): Promise<typeof SharpNs | null> {
|
|
65
|
+
if (!_sharpResolved) {
|
|
66
|
+
try {
|
|
67
|
+
_sharp = (await import('sharp')).default
|
|
68
|
+
} catch {
|
|
69
|
+
_sharp = null
|
|
70
|
+
}
|
|
71
|
+
_sharpResolved = true
|
|
72
|
+
}
|
|
73
|
+
return _sharp
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function warnSharpMissing() {
|
|
77
|
+
if (!_sharpWarned) {
|
|
78
|
+
console.warn('[lopata] sharp is not installed — image transformations will pass through unchanged. Install it: bun add sharp')
|
|
79
|
+
_sharpWarned = true
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function passthroughResult(buf: Uint8Array, format: string): ImageOutputResult {
|
|
84
|
+
return {
|
|
85
|
+
image(): ReadableStream<Uint8Array> {
|
|
86
|
+
return new ReadableStream({
|
|
87
|
+
start(controller) {
|
|
88
|
+
controller.enqueue(buf)
|
|
89
|
+
controller.close()
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
},
|
|
93
|
+
contentType(): string {
|
|
94
|
+
return format
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
49
99
|
// --- PNG header parsing ---
|
|
50
100
|
|
|
51
101
|
function parsePngSize(buf: Uint8Array): { width: number; height: number } | null {
|
|
@@ -229,11 +279,12 @@ async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Arra
|
|
|
229
279
|
|
|
230
280
|
// --- Sharp format mapping ---
|
|
231
281
|
|
|
232
|
-
const MIME_TO_SHARP: Record<string, 'png' | 'jpeg' | 'webp' | 'avif'> = {
|
|
282
|
+
const MIME_TO_SHARP: Record<string, 'png' | 'jpeg' | 'webp' | 'avif' | 'gif'> = {
|
|
233
283
|
'image/png': 'png',
|
|
234
284
|
'image/jpeg': 'jpeg',
|
|
235
285
|
'image/webp': 'webp',
|
|
236
286
|
'image/avif': 'avif',
|
|
287
|
+
'image/gif': 'gif',
|
|
237
288
|
}
|
|
238
289
|
|
|
239
290
|
const CF_FIT_TO_SHARP: Record<string, 'contain' | 'cover' | 'fill' | 'inside' | 'outside'> = {
|
|
@@ -244,6 +295,28 @@ const CF_FIT_TO_SHARP: Record<string, 'contain' | 'cover' | 'fill' | 'inside' |
|
|
|
244
295
|
pad: 'contain',
|
|
245
296
|
}
|
|
246
297
|
|
|
298
|
+
const QUALITY_PRESETS: Record<string, number> = {
|
|
299
|
+
high: 85,
|
|
300
|
+
'medium-high': 72,
|
|
301
|
+
'medium-low': 50,
|
|
302
|
+
low: 30,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function resolveQuality(q: number | string | undefined): number | undefined {
|
|
306
|
+
if (q === undefined) return undefined
|
|
307
|
+
if (typeof q === 'number') return q
|
|
308
|
+
return QUALITY_PRESETS[q]
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const CF_GRAVITY_TO_SHARP: Record<string, string> = {
|
|
312
|
+
auto: 'attention',
|
|
313
|
+
center: 'centre',
|
|
314
|
+
left: 'west',
|
|
315
|
+
right: 'east',
|
|
316
|
+
top: 'north',
|
|
317
|
+
bottom: 'south',
|
|
318
|
+
}
|
|
319
|
+
|
|
247
320
|
// --- LazyImageTransformer: Sharp-based ---
|
|
248
321
|
|
|
249
322
|
class LazyImageTransformer {
|
|
@@ -266,13 +339,29 @@ class LazyImageTransformer {
|
|
|
266
339
|
}
|
|
267
340
|
|
|
268
341
|
async output(options: OutputOptions): Promise<ImageOutputResult> {
|
|
342
|
+
const sharp = await getSharp()
|
|
343
|
+
if (!sharp) {
|
|
344
|
+
warnSharpMissing()
|
|
345
|
+
return passthroughResult(await this.streamPromise, options.format)
|
|
346
|
+
}
|
|
347
|
+
|
|
269
348
|
let currentBuf = Buffer.from(await this.streamPromise)
|
|
349
|
+
let transformFormat: string | undefined
|
|
270
350
|
|
|
271
351
|
// Apply each transform as a separate Sharp pipeline to ensure correct ordering
|
|
272
352
|
// (Sharp internally reorders operations within a single pipeline)
|
|
273
353
|
for (const t of this.transforms) {
|
|
354
|
+
if (t.format) transformFormat = t.format
|
|
274
355
|
let pipeline = sharp(currentBuf)
|
|
275
356
|
|
|
357
|
+
// DPR: multiply dimensions before resize
|
|
358
|
+
let effectiveWidth = t.width
|
|
359
|
+
let effectiveHeight = t.height
|
|
360
|
+
if (t.dpr && t.dpr !== 1) {
|
|
361
|
+
if (effectiveWidth) effectiveWidth = Math.round(effectiveWidth * t.dpr)
|
|
362
|
+
if (effectiveHeight) effectiveHeight = Math.round(effectiveHeight * t.dpr)
|
|
363
|
+
}
|
|
364
|
+
|
|
276
365
|
if (t.rotate !== undefined && t.rotate !== 0) {
|
|
277
366
|
pipeline = pipeline.rotate(t.rotate)
|
|
278
367
|
}
|
|
@@ -282,11 +371,21 @@ class LazyImageTransformer {
|
|
|
282
371
|
if (t.flop) {
|
|
283
372
|
pipeline = pipeline.flop()
|
|
284
373
|
}
|
|
285
|
-
if (
|
|
374
|
+
if (effectiveWidth !== undefined || effectiveHeight !== undefined) {
|
|
286
375
|
const fitVal = t.fit ? CF_FIT_TO_SHARP[t.fit] ?? 'cover' : 'cover'
|
|
287
|
-
const resizeOpts:
|
|
376
|
+
const resizeOpts: SharpNs.ResizeOptions = { fit: fitVal }
|
|
288
377
|
if (t.background) resizeOpts.background = t.background
|
|
289
|
-
|
|
378
|
+
// Map gravity for resize positioning
|
|
379
|
+
if (t.gravity) {
|
|
380
|
+
if (typeof t.gravity === 'string') {
|
|
381
|
+
const mapped = CF_GRAVITY_TO_SHARP[t.gravity]
|
|
382
|
+
if (mapped) resizeOpts.position = mapped as any
|
|
383
|
+
} else {
|
|
384
|
+
// { x, y } — Sharp doesn't directly support arbitrary x,y so use percentage-based
|
|
385
|
+
resizeOpts.position = `${Math.round(t.gravity.x * 100)}% ${Math.round(t.gravity.y * 100)}%` as any
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
pipeline = pipeline.resize(effectiveWidth ?? null, effectiveHeight ?? null, resizeOpts)
|
|
290
389
|
}
|
|
291
390
|
if (t.blur !== undefined && t.blur > 0) {
|
|
292
391
|
pipeline = pipeline.blur(Math.max(t.blur, 0.3))
|
|
@@ -294,8 +393,36 @@ class LazyImageTransformer {
|
|
|
294
393
|
if (t.sharpen !== undefined && t.sharpen > 0) {
|
|
295
394
|
pipeline = pipeline.sharpen(t.sharpen)
|
|
296
395
|
}
|
|
297
|
-
|
|
298
|
-
|
|
396
|
+
// Brightness and saturation via modulate
|
|
397
|
+
if ((t.brightness !== undefined && t.brightness !== 1) || (t.saturation !== undefined && t.saturation !== 1)) {
|
|
398
|
+
const modulateOpts: { brightness?: number; saturation?: number } = {}
|
|
399
|
+
if (t.brightness !== undefined) modulateOpts.brightness = t.brightness
|
|
400
|
+
if (t.saturation !== undefined) modulateOpts.saturation = t.saturation
|
|
401
|
+
pipeline = pipeline.modulate(modulateOpts)
|
|
402
|
+
}
|
|
403
|
+
// Contrast via linear transform: output = contrast * input + (128 * (1 - contrast))
|
|
404
|
+
if (t.contrast !== undefined && t.contrast !== 1) {
|
|
405
|
+
pipeline = pipeline.linear(t.contrast, 128 * (1 - t.contrast))
|
|
406
|
+
}
|
|
407
|
+
// Gamma
|
|
408
|
+
if (t.gamma !== undefined && t.gamma !== 1) {
|
|
409
|
+
pipeline = pipeline.gamma(t.gamma)
|
|
410
|
+
}
|
|
411
|
+
// Trim — CF's trim is a threshold value
|
|
412
|
+
if (t.trim !== undefined && t.trim !== false) {
|
|
413
|
+
const threshold = typeof t.trim === 'number' ? t.trim : 10
|
|
414
|
+
pipeline = pipeline.trim({ threshold })
|
|
415
|
+
}
|
|
416
|
+
// Border — extend image edges
|
|
417
|
+
if (t.border) {
|
|
418
|
+
const borderWidth = t.border.width ?? 0
|
|
419
|
+
pipeline = pipeline.extend({
|
|
420
|
+
top: t.border.top ?? borderWidth,
|
|
421
|
+
right: t.border.right ?? borderWidth,
|
|
422
|
+
bottom: t.border.bottom ?? borderWidth,
|
|
423
|
+
left: t.border.left ?? borderWidth,
|
|
424
|
+
background: t.border.color ?? '#000000',
|
|
425
|
+
})
|
|
299
426
|
}
|
|
300
427
|
|
|
301
428
|
currentBuf = Buffer.from(await pipeline.toBuffer())
|
|
@@ -303,18 +430,58 @@ class LazyImageTransformer {
|
|
|
303
430
|
|
|
304
431
|
// Apply draw overlays
|
|
305
432
|
if (this.overlays.length > 0) {
|
|
306
|
-
const
|
|
433
|
+
const baseMeta = await sharp(currentBuf).metadata()
|
|
434
|
+
const composites: SharpNs.OverlayOptions[] = []
|
|
307
435
|
for (const overlay of this.overlays) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (overlay.options?.
|
|
312
|
-
|
|
313
|
-
|
|
436
|
+
let overlayBuf = Buffer.from(await overlay.streamPromise)
|
|
437
|
+
|
|
438
|
+
// Apply opacity to overlay by compositing with a semi-transparent blank
|
|
439
|
+
if (overlay.options?.opacity !== undefined && overlay.options.opacity < 1) {
|
|
440
|
+
const overlayMeta = await sharp(overlayBuf).metadata()
|
|
441
|
+
const w = overlayMeta.width ?? 1
|
|
442
|
+
const h = overlayMeta.height ?? 1
|
|
443
|
+
const alpha = Math.round(overlay.options.opacity * 255)
|
|
444
|
+
// Create a semi-transparent overlay: ensure alpha, then composite with a mask
|
|
445
|
+
overlayBuf = await sharp(overlayBuf)
|
|
446
|
+
.ensureAlpha()
|
|
447
|
+
.composite([{
|
|
448
|
+
input: Buffer.from(new Uint8Array(w * h * 4).fill(0).map((_, i) => i % 4 === 3 ? alpha : 255)),
|
|
449
|
+
raw: { width: w, height: h, channels: 4 },
|
|
450
|
+
blend: 'dest-in' as any,
|
|
451
|
+
}])
|
|
452
|
+
.toBuffer() as Buffer<ArrayBuffer>
|
|
314
453
|
}
|
|
315
|
-
|
|
316
|
-
|
|
454
|
+
|
|
455
|
+
const opts: SharpNs.OverlayOptions = { input: overlayBuf }
|
|
456
|
+
|
|
457
|
+
// Compute position — handle bottom/right by converting to top/left
|
|
458
|
+
const hasTop = overlay.options?.top !== undefined
|
|
459
|
+
const hasLeft = overlay.options?.left !== undefined
|
|
460
|
+
const hasBottom = overlay.options?.bottom !== undefined
|
|
461
|
+
const hasRight = overlay.options?.right !== undefined
|
|
462
|
+
|
|
463
|
+
if (hasBottom || hasRight) {
|
|
464
|
+
const overlayMeta = await sharp(overlayBuf).metadata()
|
|
465
|
+
const baseW = baseMeta.width ?? 0
|
|
466
|
+
const baseH = baseMeta.height ?? 0
|
|
467
|
+
const overlayW = overlayMeta.width ?? 0
|
|
468
|
+
const overlayH = overlayMeta.height ?? 0
|
|
469
|
+
|
|
470
|
+
if (hasTop) {
|
|
471
|
+
opts.top = overlay.options!.top
|
|
472
|
+
} else if (hasBottom) {
|
|
473
|
+
opts.top = Math.max(0, baseH - overlayH - overlay.options!.bottom!)
|
|
474
|
+
}
|
|
475
|
+
if (hasLeft) {
|
|
476
|
+
opts.left = overlay.options!.left
|
|
477
|
+
} else if (hasRight) {
|
|
478
|
+
opts.left = Math.max(0, baseW - overlayW - overlay.options!.right!)
|
|
479
|
+
}
|
|
480
|
+
} else {
|
|
481
|
+
if (hasTop) opts.top = overlay.options!.top
|
|
482
|
+
if (hasLeft) opts.left = overlay.options!.left
|
|
317
483
|
}
|
|
484
|
+
|
|
318
485
|
if (overlay.options?.repeat === 'repeat') {
|
|
319
486
|
opts.tile = true
|
|
320
487
|
}
|
|
@@ -323,14 +490,50 @@ class LazyImageTransformer {
|
|
|
323
490
|
currentBuf = Buffer.from(await sharp(currentBuf).composite(composites).toBuffer())
|
|
324
491
|
}
|
|
325
492
|
|
|
326
|
-
//
|
|
327
|
-
|
|
493
|
+
// Resolve output format — transform format overrides output if set
|
|
494
|
+
// "auto" → detect best format from source (default to webp)
|
|
495
|
+
let resolvedMime = options.format
|
|
496
|
+
if (transformFormat) {
|
|
497
|
+
if (transformFormat === 'auto') {
|
|
498
|
+
// Detect source format from buffer, default to webp
|
|
499
|
+
const sourceFormat = detectFormat(currentBuf)
|
|
500
|
+
resolvedMime = sourceFormat && sourceFormat !== 'image/svg+xml' ? sourceFormat : 'image/webp'
|
|
501
|
+
} else {
|
|
502
|
+
const shortToMime: Record<string, OutputOptions['format']> = {
|
|
503
|
+
avif: 'image/avif',
|
|
504
|
+
webp: 'image/webp',
|
|
505
|
+
jpeg: 'image/jpeg',
|
|
506
|
+
png: 'image/png',
|
|
507
|
+
gif: 'image/gif',
|
|
508
|
+
}
|
|
509
|
+
resolvedMime = shortToMime[transformFormat] ?? resolvedMime
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const sharpFmt = MIME_TO_SHARP[resolvedMime] ?? 'png'
|
|
328
513
|
const formatOpts: Record<string, unknown> = {}
|
|
329
|
-
|
|
330
|
-
|
|
514
|
+
const resolvedQuality = resolveQuality(options.quality)
|
|
515
|
+
if (resolvedQuality !== undefined) {
|
|
516
|
+
formatOpts.quality = resolvedQuality
|
|
517
|
+
}
|
|
518
|
+
// Compression: "fast" → lower effort for supported formats
|
|
519
|
+
if (options.compression === 'fast') {
|
|
520
|
+
if (sharpFmt === 'png') formatOpts.compressionLevel = 1
|
|
521
|
+
if (sharpFmt === 'webp') formatOpts.effort = 0
|
|
522
|
+
if (sharpFmt === 'avif') formatOpts.effort = 0
|
|
331
523
|
}
|
|
332
|
-
|
|
333
|
-
|
|
524
|
+
|
|
525
|
+
let outputPipeline = sharp(currentBuf)
|
|
526
|
+
|
|
527
|
+
// Metadata handling
|
|
528
|
+
if (options.metadata === 'keep') {
|
|
529
|
+
outputPipeline = outputPipeline.keepMetadata()
|
|
530
|
+
} else if (options.metadata === 'copyright') {
|
|
531
|
+
outputPipeline = outputPipeline.withMetadata()
|
|
532
|
+
}
|
|
533
|
+
// 'none' is the default — Sharp strips metadata by default
|
|
534
|
+
|
|
535
|
+
const outputBuf = await outputPipeline.toFormat(sharpFmt, formatOpts).toBuffer()
|
|
536
|
+
const contentType = resolvedMime
|
|
334
537
|
|
|
335
538
|
return {
|
|
336
539
|
image(): ReadableStream<Uint8Array> {
|
|
@@ -360,14 +563,16 @@ export class ImagesBinding {
|
|
|
360
563
|
// Try our fast header parsers first, fall back to Sharp for AVIF
|
|
361
564
|
let dims = parseDimensions(buf, format)
|
|
362
565
|
if (!dims && format === 'image/avif') {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
566
|
+
const sharp = await getSharp()
|
|
567
|
+
if (sharp) {
|
|
568
|
+
try {
|
|
569
|
+
const meta = await sharp(Buffer.from(buf)).metadata()
|
|
570
|
+
if (meta.width && meta.height) {
|
|
571
|
+
dims = { width: meta.width, height: meta.height }
|
|
572
|
+
}
|
|
573
|
+
} catch {
|
|
574
|
+
// ignore — return 0,0
|
|
368
575
|
}
|
|
369
|
-
} catch {
|
|
370
|
-
// ignore — return 0,0
|
|
371
576
|
}
|
|
372
577
|
}
|
|
373
578
|
return {
|
package/src/bindings/queue.ts
CHANGED
|
@@ -39,6 +39,8 @@ interface ConsumerConfig {
|
|
|
39
39
|
maxBatchTimeout: number
|
|
40
40
|
maxRetries: number
|
|
41
41
|
deadLetterQueue: string | null
|
|
42
|
+
maxConcurrency?: number | null
|
|
43
|
+
retryDelay?: number | null
|
|
42
44
|
retentionPeriodSeconds?: number // default 345600 (4 days), matching CF default
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -195,6 +197,7 @@ export class QueueConsumer {
|
|
|
195
197
|
private batchBuffer: { id: string; body: Uint8Array | Buffer; content_type: string; attempts: number; created_at: number }[] = []
|
|
196
198
|
private batchTimer: ReturnType<typeof setTimeout> | null = null
|
|
197
199
|
private polling = false
|
|
200
|
+
private _activeDeliveries = 0
|
|
198
201
|
|
|
199
202
|
constructor(
|
|
200
203
|
db: Database,
|
|
@@ -230,6 +233,8 @@ export class QueueConsumer {
|
|
|
230
233
|
|
|
231
234
|
async poll(): Promise<void> {
|
|
232
235
|
if (this.polling) return
|
|
236
|
+
// Check max_concurrency gate
|
|
237
|
+
if (this.config.maxConcurrency && this._activeDeliveries >= this.config.maxConcurrency) return
|
|
233
238
|
this.polling = true
|
|
234
239
|
try {
|
|
235
240
|
const now = Date.now()
|
|
@@ -258,6 +263,17 @@ export class QueueConsumer {
|
|
|
258
263
|
|
|
259
264
|
private async deliverBatch(
|
|
260
265
|
rows: { id: string; body: Uint8Array | Buffer; content_type: string; attempts: number; created_at: number }[],
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
this._activeDeliveries++
|
|
268
|
+
try {
|
|
269
|
+
await this._deliverBatchInner(rows)
|
|
270
|
+
} finally {
|
|
271
|
+
this._activeDeliveries--
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async _deliverBatchInner(
|
|
276
|
+
rows: { id: string; body: Uint8Array | Buffer; content_type: string; attempts: number; created_at: number }[],
|
|
261
277
|
): Promise<void> {
|
|
262
278
|
// Increment attempts for all fetched messages
|
|
263
279
|
const ids = rows.map((r) => r.id)
|
|
@@ -330,7 +346,7 @@ export class QueueConsumer {
|
|
|
330
346
|
this.db.run("UPDATE queue_messages SET status = 'acked', completed_at = ? WHERE id = ?", [Date.now(), row.id])
|
|
331
347
|
} else {
|
|
332
348
|
// Retry
|
|
333
|
-
const delay = decision.delaySeconds ?? 0
|
|
349
|
+
const delay = decision.delaySeconds ?? this.config.retryDelay ?? 0
|
|
334
350
|
if (currentAttempts >= this.config.maxRetries) {
|
|
335
351
|
// Max retries exceeded — move to DLQ or mark as failed
|
|
336
352
|
if (this.config.deadLetterQueue) {
|
|
@@ -8,9 +8,11 @@ export interface ScheduledController {
|
|
|
8
8
|
noRetry(): void
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// A CronField matcher that can check a value, optionally with full date context
|
|
12
|
+
type CronFieldMatcher = (value: number, date: Date) => boolean
|
|
13
|
+
|
|
11
14
|
interface CronField {
|
|
12
|
-
|
|
13
|
-
values: number[]
|
|
15
|
+
match: CronFieldMatcher
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
interface ParsedCron {
|
|
@@ -65,39 +67,161 @@ function resolveToken(token: string, names: Record<string, number> | null): numb
|
|
|
65
67
|
return parseInt(token, 10)
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
/** Get the last day of a given month (1-indexed) */
|
|
71
|
+
function lastDayOfMonth(year: number, month: number): number {
|
|
72
|
+
return new Date(year, month, 0).getDate()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Get the day-of-week (0=Sun) for a given date */
|
|
76
|
+
function dayOfWeekFor(year: number, month: number, day: number): number {
|
|
77
|
+
return new Date(year, month - 1, day).getDay()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Find nearest weekday to the given day in the given month */
|
|
81
|
+
function nearestWeekday(year: number, month: number, day: number): number {
|
|
82
|
+
const lastDay = lastDayOfMonth(year, month)
|
|
83
|
+
day = Math.min(day, lastDay)
|
|
84
|
+
const dow = dayOfWeekFor(year, month, day)
|
|
85
|
+
if (dow >= 1 && dow <= 5) return day // already weekday
|
|
86
|
+
if (dow === 6) {
|
|
87
|
+
// Saturday → Friday or Monday
|
|
88
|
+
return day > 1 ? day - 1 : day + 2
|
|
89
|
+
}
|
|
90
|
+
// Sunday → Monday or Friday
|
|
91
|
+
return day < lastDay ? day + 1 : day - 2
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Find the Nth occurrence of a weekday in a month (1-based N) */
|
|
95
|
+
function nthWeekdayOfMonth(year: number, month: number, weekday: number, n: number): number | null {
|
|
96
|
+
let count = 0
|
|
97
|
+
const lastDay = lastDayOfMonth(year, month)
|
|
98
|
+
for (let d = 1; d <= lastDay; d++) {
|
|
99
|
+
if (dayOfWeekFor(year, month, d) === weekday) {
|
|
100
|
+
count++
|
|
101
|
+
if (count === n) return d
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null // Nth occurrence doesn't exist
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Find the last occurrence of a weekday in a month */
|
|
108
|
+
function lastWeekdayOfMonth(year: number, month: number, weekday: number): number {
|
|
109
|
+
const lastDay = lastDayOfMonth(year, month)
|
|
110
|
+
for (let d = lastDay; d >= 1; d--) {
|
|
111
|
+
if (dayOfWeekFor(year, month, d) === weekday) return d
|
|
112
|
+
}
|
|
113
|
+
return lastDay // should never reach here
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type FieldType = 'minute' | 'hour' | 'dayOfMonth' | 'month' | 'dayOfWeek'
|
|
117
|
+
|
|
118
|
+
function parseField(field: string, min: number, max: number, fieldType: FieldType, names: Record<string, number> | null = null): CronField {
|
|
69
119
|
if (field === '*') {
|
|
70
|
-
return {
|
|
120
|
+
return { match: () => true }
|
|
71
121
|
}
|
|
72
122
|
|
|
73
|
-
|
|
123
|
+
// Collect matchers for each comma-separated part
|
|
124
|
+
const matchers: CronFieldMatcher[] = []
|
|
74
125
|
|
|
75
126
|
for (const part of field.split(',')) {
|
|
127
|
+
// LW — last weekday of month (day-of-month field only)
|
|
128
|
+
if (fieldType === 'dayOfMonth' && part.toUpperCase() === 'LW') {
|
|
129
|
+
matchers.push((_value, date) => {
|
|
130
|
+
const year = date.getFullYear()
|
|
131
|
+
const month = date.getMonth() + 1
|
|
132
|
+
const last = lastDayOfMonth(year, month)
|
|
133
|
+
const dow = dayOfWeekFor(year, month, last)
|
|
134
|
+
let lw: number
|
|
135
|
+
if (dow === 0) lw = last - 2 // Sun → Fri
|
|
136
|
+
else if (dow === 6) lw = last - 1 // Sat → Fri
|
|
137
|
+
else lw = last
|
|
138
|
+
return date.getDate() === lw
|
|
139
|
+
})
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// L in day-of-month — last day of month
|
|
144
|
+
if (fieldType === 'dayOfMonth' && part.toUpperCase() === 'L') {
|
|
145
|
+
matchers.push((_value, date) => {
|
|
146
|
+
return date.getDate() === lastDayOfMonth(date.getFullYear(), date.getMonth() + 1)
|
|
147
|
+
})
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// W in day-of-month — nearest weekday (e.g. 15W)
|
|
152
|
+
if (fieldType === 'dayOfMonth') {
|
|
153
|
+
const wMatch = part.match(/^(\d+)W$/i)
|
|
154
|
+
if (wMatch) {
|
|
155
|
+
const targetDay = parseInt(wMatch[1]!, 10)
|
|
156
|
+
matchers.push((_value, date) => {
|
|
157
|
+
return date.getDate() === nearestWeekday(date.getFullYear(), date.getMonth() + 1, targetDay)
|
|
158
|
+
})
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// # in day-of-week — Nth occurrence (e.g. 2#3 = 3rd Tuesday)
|
|
164
|
+
if (fieldType === 'dayOfWeek') {
|
|
165
|
+
const hashMatch = part.match(/^([a-zA-Z0-9]+)#(\d+)$/)
|
|
166
|
+
if (hashMatch) {
|
|
167
|
+
const weekday = resolveToken(hashMatch[1]!, names)
|
|
168
|
+
const n = parseInt(hashMatch[2]!, 10)
|
|
169
|
+
matchers.push((_value, date) => {
|
|
170
|
+
const nth = nthWeekdayOfMonth(date.getFullYear(), date.getMonth() + 1, weekday, n)
|
|
171
|
+
return nth !== null && date.getDate() === nth
|
|
172
|
+
})
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// L in day-of-week — last occurrence of that weekday (e.g. 5L or FRIL)
|
|
178
|
+
if (fieldType === 'dayOfWeek') {
|
|
179
|
+
const lMatch = part.match(/^([a-zA-Z0-9]+)L$/i)
|
|
180
|
+
if (lMatch) {
|
|
181
|
+
const weekday = resolveToken(lMatch[1]!, names)
|
|
182
|
+
matchers.push((_value, date) => {
|
|
183
|
+
const last = lastWeekdayOfMonth(date.getFullYear(), date.getMonth() + 1, weekday)
|
|
184
|
+
return date.getDate() === last
|
|
185
|
+
})
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Step: */2 or 1-5/2
|
|
76
191
|
const stepMatch = part.match(/^(\*|([a-zA-Z0-9]+)-([a-zA-Z0-9]+))\/(\d+)$/)
|
|
77
192
|
if (stepMatch) {
|
|
78
193
|
const step = parseInt(stepMatch[4]!, 10)
|
|
79
194
|
const start = stepMatch[1] === '*' ? min : resolveToken(stepMatch[2]!, names)
|
|
80
195
|
const end = stepMatch[1] === '*' ? max : resolveToken(stepMatch[3]!, names)
|
|
196
|
+
const values: number[] = []
|
|
81
197
|
for (let i = start; i <= end; i += step) {
|
|
82
198
|
values.push(i)
|
|
83
199
|
}
|
|
200
|
+
matchers.push((value) => values.includes(value))
|
|
84
201
|
continue
|
|
85
202
|
}
|
|
86
203
|
|
|
204
|
+
// Range: 1-5
|
|
87
205
|
const rangeMatch = part.match(/^([a-zA-Z0-9]+)-([a-zA-Z0-9]+)$/)
|
|
88
206
|
if (rangeMatch) {
|
|
89
207
|
const start = resolveToken(rangeMatch[1]!, names)
|
|
90
208
|
const end = resolveToken(rangeMatch[2]!, names)
|
|
209
|
+
const values: number[] = []
|
|
91
210
|
for (let i = start; i <= end; i++) {
|
|
92
211
|
values.push(i)
|
|
93
212
|
}
|
|
213
|
+
matchers.push((value) => values.includes(value))
|
|
94
214
|
continue
|
|
95
215
|
}
|
|
96
216
|
|
|
97
|
-
|
|
217
|
+
// Single value
|
|
218
|
+
const val = resolveToken(part, names)
|
|
219
|
+
matchers.push((value) => value === val)
|
|
98
220
|
}
|
|
99
221
|
|
|
100
|
-
return {
|
|
222
|
+
return {
|
|
223
|
+
match: (value, date) => matchers.some(m => m(value, date)),
|
|
224
|
+
}
|
|
101
225
|
}
|
|
102
226
|
|
|
103
227
|
export function parseCron(expression: string): ParsedCron {
|
|
@@ -118,26 +242,21 @@ export function parseCron(expression: string): ParsedCron {
|
|
|
118
242
|
|
|
119
243
|
return {
|
|
120
244
|
expression: trimmed,
|
|
121
|
-
minute: parseField(parts[0]!, 0, 59),
|
|
122
|
-
hour: parseField(parts[1]!, 0, 23),
|
|
123
|
-
dayOfMonth: parseField(parts[2]!, 1, 31),
|
|
124
|
-
month: parseField(parts[3]!, 1, 12, MONTH_NAMES),
|
|
125
|
-
dayOfWeek: parseField(parts[4]!, 0, 6, DAY_NAMES),
|
|
245
|
+
minute: parseField(parts[0]!, 0, 59, 'minute'),
|
|
246
|
+
hour: parseField(parts[1]!, 0, 23, 'hour'),
|
|
247
|
+
dayOfMonth: parseField(parts[2]!, 1, 31, 'dayOfMonth'),
|
|
248
|
+
month: parseField(parts[3]!, 1, 12, 'month', MONTH_NAMES),
|
|
249
|
+
dayOfWeek: parseField(parts[4]!, 0, 6, 'dayOfWeek', DAY_NAMES),
|
|
126
250
|
}
|
|
127
251
|
}
|
|
128
252
|
|
|
129
|
-
function fieldMatches(field: CronField, value: number): boolean {
|
|
130
|
-
if (field.type === 'any') return true
|
|
131
|
-
return field.values.includes(value)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
253
|
export function cronMatchesDate(cron: ParsedCron, date: Date): boolean {
|
|
135
254
|
return (
|
|
136
|
-
|
|
137
|
-
&&
|
|
138
|
-
&&
|
|
139
|
-
&&
|
|
140
|
-
&&
|
|
255
|
+
cron.minute.match(date.getMinutes(), date)
|
|
256
|
+
&& cron.hour.match(date.getHours(), date)
|
|
257
|
+
&& cron.dayOfMonth.match(date.getDate(), date)
|
|
258
|
+
&& cron.month.match(date.getMonth() + 1, date)
|
|
259
|
+
&& cron.dayOfWeek.match(date.getDay(), date)
|
|
141
260
|
)
|
|
142
261
|
}
|
|
143
262
|
|