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.
Files changed (45) hide show
  1. package/dist/dashboard/{chunk-jrv1mhg9.css → chunk-paesqsyf.css} +33 -17
  2. package/dist/dashboard/{chunk-5j5em7qa.js → chunk-ymq225fp.js} +190 -98
  3. package/dist/dashboard/index.html +1 -1
  4. package/package.json +13 -16
  5. package/src/api/dispatch.ts +2 -0
  6. package/src/api/generate-sql.ts +51 -0
  7. package/src/api/handlers/d1.ts +8 -0
  8. package/src/api/handlers/do.ts +8 -0
  9. package/src/api/handlers/email.ts +1 -1
  10. package/src/api/handlers/queue.ts +1 -1
  11. package/src/api/handlers/warnings.ts +8 -0
  12. package/src/api/index.ts +6 -1
  13. package/src/api/r2.ts +1 -1
  14. package/src/api/types.ts +2 -0
  15. package/src/bindings/browser.ts +0 -3
  16. package/src/bindings/cache.ts +1 -1
  17. package/src/bindings/container.ts +4 -4
  18. package/src/bindings/crypto-extras.ts +1 -1
  19. package/src/bindings/do-executor-inprocess.ts +4 -0
  20. package/src/bindings/do-executor-worker.ts +4 -0
  21. package/src/bindings/do-executor.ts +3 -0
  22. package/src/bindings/durable-object.ts +32 -2
  23. package/src/bindings/html-rewriter.ts +26 -1
  24. package/src/bindings/images.ts +237 -32
  25. package/src/bindings/queue.ts +17 -1
  26. package/src/bindings/scheduled.ts +141 -22
  27. package/src/bindings/service-binding.ts +10 -5
  28. package/src/bindings/static-assets.ts +13 -1
  29. package/src/bindings/workflow.ts +5 -2
  30. package/src/cli/dev.ts +2 -1
  31. package/src/cli.ts +0 -0
  32. package/src/config.ts +17 -3
  33. package/src/dashboard-serve.ts +1 -1
  34. package/src/db.ts +6 -0
  35. package/src/env.ts +40 -2
  36. package/src/execution-context.ts +5 -0
  37. package/src/generation-manager.ts +7 -3
  38. package/src/generation.ts +31 -2
  39. package/src/lopata-config.ts +7 -0
  40. package/src/plugin.ts +77 -4
  41. package/src/request-cf.ts +2 -0
  42. package/src/tracing/store.ts +1 -1
  43. package/src/tsconfig.json +1 -0
  44. package/src/vite-plugin/dev-server-plugin.ts +0 -1
  45. package/src/warnings.ts +30 -0
@@ -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 sharp from 'sharp'
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?: { top?: number; right?: number; bottom?: number; left?: number }
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 (t.width !== undefined || t.height !== undefined) {
374
+ if (effectiveWidth !== undefined || effectiveHeight !== undefined) {
286
375
  const fitVal = t.fit ? CF_FIT_TO_SHARP[t.fit] ?? 'cover' : 'cover'
287
- const resizeOpts: sharp.ResizeOptions = { fit: fitVal }
376
+ const resizeOpts: SharpNs.ResizeOptions = { fit: fitVal }
288
377
  if (t.background) resizeOpts.background = t.background
289
- pipeline = pipeline.resize(t.width ?? null, t.height ?? null, resizeOpts)
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
- if (t.brightness !== undefined && t.brightness !== 1) {
298
- pipeline = pipeline.modulate({ brightness: t.brightness })
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 composites: sharp.OverlayOptions[] = []
433
+ const baseMeta = await sharp(currentBuf).metadata()
434
+ const composites: SharpNs.OverlayOptions[] = []
307
435
  for (const overlay of this.overlays) {
308
- const overlayData = await overlay.streamPromise
309
- const opts: sharp.OverlayOptions = { input: Buffer.from(overlayData) }
310
- if (overlay.options?.top !== undefined) opts.top = overlay.options.top
311
- if (overlay.options?.left !== undefined) opts.left = overlay.options.left
312
- if (overlay.options?.bottom !== undefined && overlay.options?.top === undefined) {
313
- opts.gravity = 'south'
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
- if (overlay.options?.right !== undefined && overlay.options?.left === undefined) {
316
- opts.gravity = 'east'
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
- // Output format
327
- const sharpFmt = MIME_TO_SHARP[options.format] ?? 'png'
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
- if (options.quality !== undefined) {
330
- formatOpts.quality = options.quality
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
- const outputBuf = await sharp(currentBuf).toFormat(sharpFmt, formatOpts).toBuffer()
333
- const contentType = options.format
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
- // Fallback: use Sharp metadata for AVIF
364
- try {
365
- const meta = await sharp(Buffer.from(buf)).metadata()
366
- if (meta.width && meta.height) {
367
- dims = { width: meta.width, height: meta.height }
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 {
@@ -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
- type: 'any' | 'values'
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
- function parseField(field: string, min: number, max: number, names: Record<string, number> | null = null): CronField {
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 { type: 'any', values: [] }
120
+ return { match: () => true }
71
121
  }
72
122
 
73
- const values: number[] = []
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
- values.push(resolveToken(part, names))
217
+ // Single value
218
+ const val = resolveToken(part, names)
219
+ matchers.push((value) => value === val)
98
220
  }
99
221
 
100
- return { type: 'values', values }
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
- fieldMatches(cron.minute, date.getMinutes())
137
- && fieldMatches(cron.hour, date.getHours())
138
- && fieldMatches(cron.dayOfMonth, date.getDate())
139
- && fieldMatches(cron.month, date.getMonth() + 1)
140
- && fieldMatches(cron.dayOfWeek, date.getDay())
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