ipx 4.0.0-alpha.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,9 @@
1
1
  import { __exportAll, __toESM } from "./rolldown-runtime.mjs";
2
- import { decode, hasProtocol, joinURL, withLeadingSlash } from "./libs/ufo.mjs";
2
+ import { hasProtocol, joinURL, withLeadingSlash } from "./libs/ufo.mjs";
3
3
  import { HTTPError, defineEventHandler } from "./libs/h3.mjs";
4
4
  import { imageMeta } from "./libs/image-meta.mjs";
5
5
  import { require_etag } from "./libs/etag.mjs";
6
6
  import { require_accept_negotiator } from "./libs/@fastify/accept-negotiator.mjs";
7
- import { join, parse, resolve } from "pathe";
8
7
  function VArg(argument) {
9
8
  if (argument === "Infinity") return Infinity;
10
9
  if (argument === "undefined") return;
@@ -15,39 +14,207 @@ function VArg(argument) {
15
14
  } catch {}
16
15
  return argument;
17
16
  }
17
+ function invalidArg(name, argument, expected) {
18
+ throw new HTTPError({
19
+ statusCode: 400,
20
+ statusText: "IPX_INVALID_MODIFIER_ARG",
21
+ message: `Invalid \`${name}\` modifier argument: \`${argument}\` (expected ${expected})`
22
+ });
23
+ }
24
+ function isOmitted(argument) {
25
+ return argument === void 0 || argument === "" || argument === "undefined";
26
+ }
27
+ function VNumber(name, options = {}) {
28
+ const { min, max, integer } = options;
29
+ const expected = [integer ? "an integer" : "a number", min !== void 0 && max !== void 0 ? `between ${min} and ${max}` : min !== void 0 && `greater than or equal to ${min}` || max !== void 0 && `less than or equal to ${max}` || ""].filter(Boolean).join(" ");
30
+ return (argument) => {
31
+ if (isOmitted(argument)) return;
32
+ const value = VArg(argument);
33
+ if (typeof value === "number" && Number.isFinite(value) && (!integer || Number.isInteger(value)) && (min === void 0 || value >= min) && (max === void 0 || value <= max)) return value;
34
+ invalidArg(name, argument, expected);
35
+ };
36
+ }
37
+ function VEnum(name, values) {
38
+ const expected = `one of: ${values.join(", ")}`;
39
+ return (argument) => {
40
+ if (isOmitted(argument)) return;
41
+ if (values.includes(argument)) return argument;
42
+ invalidArg(name, argument, expected);
43
+ };
44
+ }
45
+ function VBoolean(name) {
46
+ return (argument) => {
47
+ if (isOmitted(argument)) return;
48
+ if (argument === "true" || argument === "1") return true;
49
+ if (argument === "false" || argument === "0") return false;
50
+ invalidArg(name, argument, "`true` or `false`");
51
+ };
52
+ }
53
+ const HEX_COLOR_RE = /^#?(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i;
54
+ const FN_COLOR_RE = /^(?:rgba?|hsla?|hwb)\([\d\s%,./-]+\)$/i;
55
+ const NAMED_COLOR_RE = /^[a-z]{3,20}$/i;
56
+ function VColor(name) {
57
+ return (argument) => {
58
+ if (isOmitted(argument)) return;
59
+ if (HEX_COLOR_RE.test(argument)) return argument.startsWith("#") ? argument : `#${argument}`;
60
+ if (FN_COLOR_RE.test(argument) || NAMED_COLOR_RE.test(argument)) return argument;
61
+ invalidArg(name, argument, "a hex (`f00`, `ff0000`) or named (`red`) colour");
62
+ };
63
+ }
64
+ const SIZE_RE = /^(\d+)(?:x(\d+))?$/;
65
+ function VSize(name) {
66
+ return (argument) => {
67
+ if (isOmitted(argument)) return;
68
+ const match = SIZE_RE.exec(argument);
69
+ const width = Number(match?.[1]);
70
+ const height = match?.[2] === void 0 ? width : Number(match[2]);
71
+ if (match && width > 0 && height > 0) return {
72
+ width,
73
+ height
74
+ };
75
+ invalidArg(name, argument, "`{width}x{height}` of positive integers");
76
+ };
77
+ }
78
+ function VRequired(name, mapper) {
79
+ return (argument) => {
80
+ const value = mapper(argument);
81
+ if (value === void 0) throw new HTTPError({
82
+ statusCode: 400,
83
+ statusText: "IPX_MISSING_MODIFIER_ARG",
84
+ message: `Missing \`${name}\` modifier argument`
85
+ });
86
+ return value;
87
+ };
88
+ }
18
89
  function parseArgs(arguments_, mappers) {
19
90
  const vargs = arguments_.split("_");
20
91
  return mappers.map((v, index) => v(vargs[index]));
21
92
  }
22
93
  function getHandler(key) {
23
- return handlers_exports[key];
94
+ return Object.hasOwn(handlers_exports, key) ? handlers_exports[key] : void 0;
24
95
  }
25
96
  function applyHandler(context, pipe, handler, argumentsString) {
26
97
  const arguments_ = handler.args ? parseArgs(argumentsString, handler.args) : [];
27
- return handler.apply(context, pipe, ...arguments_);
28
- }
29
- function clampDimensionsPreservingAspectRatio(sourceDimensions, desiredDimensions) {
30
- const desiredAspectRatio = desiredDimensions.width / desiredDimensions.height;
31
- let { width, height } = desiredDimensions;
32
- if (sourceDimensions.width && width > sourceDimensions.width) {
33
- width = sourceDimensions.width;
34
- height = Math.round(sourceDimensions.width / desiredAspectRatio);
98
+ try {
99
+ return handler.apply(context, pipe, ...arguments_);
100
+ } catch (error) {
101
+ throw asModifierError(error);
35
102
  }
36
- if (sourceDimensions.height && height > sourceDimensions.height) {
37
- height = sourceDimensions.height;
38
- width = Math.round(sourceDimensions.height * desiredAspectRatio);
103
+ }
104
+ function asModifierError(error) {
105
+ if (HTTPError.isError(error)) return error;
106
+ return new HTTPError({
107
+ statusCode: 400,
108
+ statusText: "IPX_INVALID_MODIFIER",
109
+ message: `Cannot apply modifier: ${error?.message || error}`,
110
+ cause: error
111
+ });
112
+ }
113
+ const SVG_NS = "http://www.w3.org/2000/svg";
114
+ function opacityOverlay(opacity, background) {
115
+ return background ? {
116
+ input: Buffer.from(`<svg xmlns="${SVG_NS}" width="1" height="1"><rect width="1" height="1" fill="${background}" fill-opacity="${1 - opacity}"/></svg>`),
117
+ tile: true,
118
+ blend: "over"
119
+ } : {
120
+ input: { create: {
121
+ width: 1,
122
+ height: 1,
123
+ channels: 4,
124
+ background: {
125
+ r: 0,
126
+ g: 0,
127
+ b: 0,
128
+ alpha: opacity
129
+ }
130
+ } },
131
+ tile: true,
132
+ blend: "dest-in"
133
+ };
134
+ }
135
+ function clampToMaxDimension(max, desired, source) {
136
+ const { width, height } = desired;
137
+ if (!max || width === void 0 && height === void 0) return desired;
138
+ const ratio = source?.width && source?.height ? source.width / source.height : void 0;
139
+ const effectiveWidth = width ?? (ratio && height ? height * ratio : void 0);
140
+ const effectiveHeight = height ?? (ratio && width ? width / ratio : void 0);
141
+ const scale = Math.min(effectiveWidth ? max / effectiveWidth : 1, effectiveHeight ? max / effectiveHeight : 1);
142
+ if (scale >= 1) return desired;
143
+ const clamp = (value) => value === void 0 ? void 0 : Math.max(1, Math.min(max, Math.round(value * scale)));
144
+ return {
145
+ width: clamp(width),
146
+ height: clamp(height)
147
+ };
148
+ }
149
+ function resizeOutputDimensions(desired, source, options = {}) {
150
+ const { width, height } = desired;
151
+ if (width === void 0 && height === void 0) return;
152
+ const sourceWidth = source?.width;
153
+ const sourceHeight = source?.height;
154
+ if (!sourceWidth || !sourceHeight) return desired;
155
+ const derive = (value) => Math.max(1, Math.round(value));
156
+ let out = {
157
+ width: width ?? derive(height * sourceWidth / sourceHeight),
158
+ height: height ?? derive(width * sourceHeight / sourceWidth)
159
+ };
160
+ if (options.fit === "outside" && width !== void 0 && height !== void 0) {
161
+ const scale = Math.max(width / sourceWidth, height / sourceHeight);
162
+ out = {
163
+ width: derive(sourceWidth * scale),
164
+ height: derive(sourceHeight * scale)
165
+ };
39
166
  }
167
+ return options.withoutEnlargement ? {
168
+ width: Math.min(out.width, sourceWidth),
169
+ height: Math.min(out.height, sourceHeight)
170
+ } : out;
171
+ }
172
+ function clampExtendEdges(max, edges, source) {
173
+ if (!max) return edges;
174
+ const [top, bottom] = clampEdges(max - (source?.height || 0), edges.top, edges.bottom);
175
+ const [left, right] = clampEdges(max - (source?.width || 0), edges.left, edges.right);
40
176
  return {
177
+ top,
178
+ right,
179
+ bottom,
180
+ left
181
+ };
182
+ }
183
+ function clampEdges(available, a, b) {
184
+ const total = (a || 0) + (b || 0);
185
+ if (total === 0 || total <= available) return [a, b];
186
+ const scale = Math.max(0, available) / total;
187
+ const clamp = (value) => value === void 0 ? void 0 : Math.floor(value * scale);
188
+ return [clamp(a), clamp(b)];
189
+ }
190
+ function clampDimensionsPreservingAspectRatio(sourceDimensions, desiredDimensions, fit) {
191
+ const { width, height } = desiredDimensions;
192
+ const scales = [sourceDimensions.width ? width / sourceDimensions.width : void 0, sourceDimensions.height ? height / sourceDimensions.height : void 0].filter((scale) => scale !== void 0);
193
+ if (scales.length === 0) return {
41
194
  width,
42
195
  height
43
196
  };
197
+ const scale = fit === "contain" ? Math.min(...scales) : Math.max(...scales);
198
+ if (scale <= 1) return {
199
+ width,
200
+ height
201
+ };
202
+ return {
203
+ width: Math.max(1, Math.round(width / scale)),
204
+ height: Math.max(1, Math.round(height / scale))
205
+ };
44
206
  }
45
207
  var handlers_exports = /* @__PURE__ */ __exportAll({
208
+ autoorient: () => autoorient,
46
209
  b: () => b,
47
210
  background: () => background,
48
211
  blur: () => blur,
212
+ brightness: () => brightness,
213
+ clahe: () => clahe,
49
214
  crop: () => crop,
215
+ dilate: () => dilate,
50
216
  enlarge: () => enlarge,
217
+ erode: () => erode,
51
218
  extend: () => extend,
52
219
  extract: () => extract,
53
220
  fit: () => fit,
@@ -58,11 +225,15 @@ var handlers_exports = /* @__PURE__ */ __exportAll({
58
225
  grayscale: () => grayscale,
59
226
  h: () => h,
60
227
  height: () => height,
228
+ hue: () => hue,
61
229
  kernel: () => kernel,
230
+ lightness: () => lightness,
231
+ linear: () => linear,
62
232
  median: () => median,
63
233
  modulate: () => modulate,
64
234
  negate: () => negate,
65
235
  normalize: () => normalize,
236
+ opacity: () => opacity,
66
237
  pos: () => pos,
67
238
  position: () => position,
68
239
  q: () => q,
@@ -70,124 +241,238 @@ var handlers_exports = /* @__PURE__ */ __exportAll({
70
241
  resize: () => resize,
71
242
  rotate: () => rotate,
72
243
  s: () => s,
244
+ saturation: () => saturation,
73
245
  sharpen: () => sharpen,
74
246
  threshold: () => threshold,
75
247
  tint: () => tint,
76
248
  trim: () => trim,
249
+ unflatten: () => unflatten,
77
250
  w: () => w,
78
251
  width: () => width
79
252
  });
80
253
  const quality = {
81
- args: [VArg],
254
+ args: [VNumber("quality", {
255
+ min: 1,
256
+ max: 100,
257
+ integer: true
258
+ })],
82
259
  order: -1,
83
260
  apply: (context, _pipe, quality) => {
84
261
  context.quality = quality;
85
262
  }
86
263
  };
87
264
  const fit = {
88
- args: [VArg],
265
+ args: [VEnum("fit", [
266
+ "contain",
267
+ "cover",
268
+ "fill",
269
+ "inside",
270
+ "outside"
271
+ ])],
89
272
  order: -1,
90
273
  apply: (context, _pipe, fit) => {
91
274
  context.fit = fit;
92
275
  }
93
276
  };
277
+ const POSITIONS = [
278
+ "top",
279
+ "right top",
280
+ "right",
281
+ "right bottom",
282
+ "bottom",
283
+ "left bottom",
284
+ "left",
285
+ "left top",
286
+ "north",
287
+ "northeast",
288
+ "east",
289
+ "southeast",
290
+ "south",
291
+ "southwest",
292
+ "west",
293
+ "northwest",
294
+ "center",
295
+ "centre",
296
+ "entropy",
297
+ "attention"
298
+ ];
299
+ const GRAVITIES = /* @__PURE__ */ new Set([
300
+ 0,
301
+ 1,
302
+ 2,
303
+ 3,
304
+ 4,
305
+ 5,
306
+ 6,
307
+ 7,
308
+ 8,
309
+ 16,
310
+ 17
311
+ ]);
312
+ const VPosition = (name) => {
313
+ const vEnum = VEnum(name, POSITIONS);
314
+ return (argument) => {
315
+ if (/^\d+$/.test(argument) && GRAVITIES.has(Number(argument))) return Number(argument);
316
+ return vEnum(argument?.replace("-", " "));
317
+ };
318
+ };
94
319
  const position = {
95
- args: [VArg],
320
+ args: [VPosition("position")],
96
321
  order: -1,
97
322
  apply: (context, _pipe, position) => {
98
323
  context.position = position;
99
324
  }
100
325
  };
101
- const HEX_RE = /^([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i;
102
- const SHORTHEX_RE = /^([\da-f])([\da-f])([\da-f])$/i;
103
326
  const background = {
104
- args: [VArg],
327
+ args: [VColor("background")],
105
328
  order: -1,
106
329
  apply: (context, _pipe, background) => {
107
- background = String(background);
108
- if (!background.startsWith("#") && (HEX_RE.test(background) || SHORTHEX_RE.test(background))) background = "#" + background;
109
330
  context.background = background;
110
331
  }
111
332
  };
112
333
  const enlarge = {
113
- args: [],
114
- apply: (context) => {
115
- context.enlarge = true;
334
+ args: [VBoolean("enlarge")],
335
+ apply: (context, _pipe, enabled) => {
336
+ context.enlarge = enabled !== false;
116
337
  }
117
338
  };
118
339
  const kernel = {
119
- args: [VArg],
340
+ args: [VEnum("kernel", [
341
+ "nearest",
342
+ "linear",
343
+ "cubic",
344
+ "mitchell",
345
+ "lanczos2",
346
+ "lanczos3",
347
+ "mks2013",
348
+ "mks2021"
349
+ ])],
120
350
  apply: (context, _pipe, kernel) => {
121
351
  context.kernel = kernel;
122
352
  }
123
353
  };
124
354
  const width = {
125
- args: [VArg],
355
+ args: [VNumber("width", {
356
+ min: 1,
357
+ integer: true
358
+ })],
126
359
  apply: (context, pipe, width) => {
127
- return pipe.resize(width, void 0, { withoutEnlargement: !context.enlarge });
360
+ const clamped = clampToMaxDimension(context.maxOutputDimension, { width }, context.meta);
361
+ const withoutEnlargement = !context.enlarge;
362
+ context.outputDimensions = resizeOutputDimensions(clamped, context.meta, { withoutEnlargement });
363
+ return pipe.resize(clamped.width, void 0, { withoutEnlargement });
128
364
  }
129
365
  };
130
366
  const height = {
131
- args: [VArg],
367
+ args: [VNumber("height", {
368
+ min: 1,
369
+ integer: true
370
+ })],
132
371
  apply: (context, pipe, height) => {
133
- return pipe.resize(void 0, height, { withoutEnlargement: !context.enlarge });
372
+ const clamped = clampToMaxDimension(context.maxOutputDimension, { height }, context.meta);
373
+ const withoutEnlargement = !context.enlarge;
374
+ context.outputDimensions = resizeOutputDimensions(clamped, context.meta, { withoutEnlargement });
375
+ return pipe.resize(void 0, clamped.height, { withoutEnlargement });
134
376
  }
135
377
  };
136
378
  const resize = {
137
- args: [
138
- VArg,
139
- VArg,
140
- VArg
141
- ],
379
+ args: [VSize("resize")],
142
380
  apply: (context, pipe, size) => {
143
- let [width, height] = String(size).split("x").map(Number);
144
- if (!width) return;
145
- if (!height) height = width;
146
- if (!context.enlarge) {
381
+ if (!size) return;
382
+ let { width, height } = size;
383
+ const keepsSourceAspectRatio = context.fit === "inside" || context.fit === "outside";
384
+ const withoutEnlargement = !context.enlarge && keepsSourceAspectRatio;
385
+ if (!context.enlarge && !keepsSourceAspectRatio) {
147
386
  const clamped = clampDimensionsPreservingAspectRatio(context.meta, {
148
387
  width,
149
388
  height
150
- });
389
+ }, context.fit);
151
390
  width = clamped.width;
152
391
  height = clamped.height;
153
392
  }
154
- return pipe.resize(width, height, {
393
+ const capped = clampToMaxDimension(context.maxOutputDimension, {
394
+ width,
395
+ height
396
+ });
397
+ context.outputDimensions = resizeOutputDimensions(capped, context.meta, {
398
+ fit: context.fit,
399
+ withoutEnlargement
400
+ });
401
+ return pipe.resize(capped.width, capped.height, {
155
402
  fit: context.fit,
156
403
  position: context.position,
157
404
  background: context.background,
158
- kernel: context.kernel
405
+ kernel: context.kernel,
406
+ withoutEnlargement
159
407
  });
160
408
  }
161
409
  };
162
410
  const trim = {
163
- args: [VArg],
411
+ args: [VNumber("trim", { min: 0 })],
164
412
  apply: (_context, pipe, threshold) => {
165
- return pipe.trim(threshold);
413
+ return pipe.trim({ threshold });
166
414
  }
167
415
  };
416
+ const EXTEND_WITH = [
417
+ "background",
418
+ "copy",
419
+ "repeat",
420
+ "mirror"
421
+ ];
422
+ const VExtendEdge = (name) => VNumber(name, {
423
+ min: 0,
424
+ max: 1e4,
425
+ integer: true
426
+ });
168
427
  const extend = {
169
428
  args: [
170
- VArg,
171
- VArg,
172
- VArg,
173
- VArg
429
+ VExtendEdge("extend.top"),
430
+ VExtendEdge("extend.right"),
431
+ VExtendEdge("extend.bottom"),
432
+ VExtendEdge("extend.left"),
433
+ VEnum("extend.extendWith", EXTEND_WITH)
174
434
  ],
175
- apply: (context, pipe, top, right, bottom, left) => {
176
- return pipe.extend({
435
+ order: 1,
436
+ apply: (context, pipe, top, right, bottom, left, extendWith) => {
437
+ const edges = clampExtendEdges(context.maxOutputDimension, {
177
438
  top,
178
- left,
179
- bottom,
180
439
  right,
181
- background: context.background
440
+ bottom,
441
+ left
442
+ }, context.outputDimensions ?? context.meta);
443
+ return pipe.extend({
444
+ top: edges.top,
445
+ left: edges.left,
446
+ bottom: edges.bottom,
447
+ right: edges.right,
448
+ background: context.background,
449
+ extendWith
182
450
  });
183
451
  }
184
452
  };
453
+ const MAX_EXTRACT = 1e8;
185
454
  const extract = {
186
455
  args: [
187
- VArg,
188
- VArg,
189
- VArg,
190
- VArg
456
+ VRequired("extract.left", VNumber("extract.left", {
457
+ min: 0,
458
+ max: MAX_EXTRACT,
459
+ integer: true
460
+ })),
461
+ VRequired("extract.top", VNumber("extract.top", {
462
+ min: 0,
463
+ max: MAX_EXTRACT,
464
+ integer: true
465
+ })),
466
+ VRequired("extract.width", VNumber("extract.width", {
467
+ min: 1,
468
+ max: MAX_EXTRACT,
469
+ integer: true
470
+ })),
471
+ VRequired("extract.height", VNumber("extract.height", {
472
+ min: 1,
473
+ max: MAX_EXTRACT,
474
+ integer: true
475
+ }))
191
476
  ],
192
477
  apply: (_context, pipe, left, top, width, height) => {
193
478
  return pipe.extract({
@@ -199,9 +484,18 @@ const extract = {
199
484
  }
200
485
  };
201
486
  const rotate = {
202
- args: [VArg],
203
- apply: (context, pipe, angel) => {
204
- return pipe.rotate(angel, { background: context.background });
487
+ args: [VNumber("rotate", {
488
+ min: -3600,
489
+ max: 3600
490
+ })],
491
+ apply: (context, pipe, angle) => {
492
+ return pipe.rotate(angle, { background: context.background });
493
+ }
494
+ };
495
+ const autoorient = {
496
+ args: [],
497
+ apply: (_context, pipe) => {
498
+ return pipe.autoOrient();
205
499
  }
206
500
  };
207
501
  const flip = {
@@ -218,102 +512,243 @@ const flop = {
218
512
  };
219
513
  const sharpen = {
220
514
  args: [
221
- VArg,
222
- VArg,
223
- VArg
515
+ VNumber("sharpen.sigma", {
516
+ min: 1e-6,
517
+ max: 10
518
+ }),
519
+ VNumber("sharpen.flat", {
520
+ min: 0,
521
+ max: 1e6
522
+ }),
523
+ VNumber("sharpen.jagged", {
524
+ min: 0,
525
+ max: 1e6
526
+ }),
527
+ VNumber("sharpen.x1", {
528
+ min: 0,
529
+ max: 1e6
530
+ }),
531
+ VNumber("sharpen.y2", {
532
+ min: 0,
533
+ max: 1e6
534
+ }),
535
+ VNumber("sharpen.y3", {
536
+ min: 0,
537
+ max: 1e6
538
+ })
224
539
  ],
225
- apply: (_context, pipe, sigma, flat, jagged) => {
226
- return pipe.sharpen({
540
+ apply: (_context, pipe, sigma, flat, jagged, x1, y2, y3) => {
541
+ return sigma === void 0 ? pipe.sharpen() : pipe.sharpen({
227
542
  sigma,
228
543
  m1: flat,
229
- m2: jagged
544
+ m2: jagged,
545
+ x1,
546
+ y2,
547
+ y3
230
548
  });
231
549
  }
232
550
  };
233
551
  const median = {
234
- args: [
235
- VArg,
236
- VArg,
237
- VArg
238
- ],
552
+ args: [VNumber("median", {
553
+ min: 1,
554
+ max: 1e3,
555
+ integer: true
556
+ })],
239
557
  apply: (_context, pipe, size) => {
240
558
  return pipe.median(size);
241
559
  }
242
560
  };
243
561
  const blur = {
244
562
  args: [
245
- VArg,
246
- VArg,
247
- VArg
563
+ VNumber("blur.sigma", {
564
+ min: .3,
565
+ max: 1e3
566
+ }),
567
+ VEnum("blur.precision", [
568
+ "integer",
569
+ "float",
570
+ "approximate"
571
+ ]),
572
+ VNumber("blur.minAmplitude", {
573
+ min: .001,
574
+ max: 1
575
+ })
248
576
  ],
249
- apply: (_context, pipe, sigma) => {
250
- return pipe.blur(sigma);
577
+ apply: (_context, pipe, sigma, precision, minAmplitude) => {
578
+ return sigma === void 0 ? pipe.blur() : pipe.blur({
579
+ sigma,
580
+ ...precision === void 0 ? {} : { precision },
581
+ ...minAmplitude === void 0 ? {} : { minAmplitude }
582
+ });
251
583
  }
252
584
  };
253
- const flatten = {
585
+ const MAX_MORPHOLOGY = 100;
586
+ const dilate = {
587
+ args: [VNumber("dilate", {
588
+ min: 1,
589
+ max: MAX_MORPHOLOGY,
590
+ integer: true
591
+ })],
592
+ apply: (_context, pipe, width) => {
593
+ return pipe.dilate(width);
594
+ }
595
+ };
596
+ const erode = {
597
+ args: [VNumber("erode", {
598
+ min: 1,
599
+ max: MAX_MORPHOLOGY,
600
+ integer: true
601
+ })],
602
+ apply: (_context, pipe, width) => {
603
+ return pipe.erode(width);
604
+ }
605
+ };
606
+ const MAX_CLAHE = 100;
607
+ const clahe = {
254
608
  args: [
255
- VArg,
256
- VArg,
257
- VArg
609
+ VRequired("clahe.width", VNumber("clahe.width", {
610
+ min: 1,
611
+ max: MAX_CLAHE,
612
+ integer: true
613
+ })),
614
+ VNumber("clahe.height", {
615
+ min: 1,
616
+ max: MAX_CLAHE,
617
+ integer: true
618
+ }),
619
+ VNumber("clahe.maxSlope", {
620
+ min: 0,
621
+ max: 100,
622
+ integer: true
623
+ })
258
624
  ],
625
+ apply: (context, pipe, width, height, maxSlope) => {
626
+ return pipe.clahe({
627
+ width: Math.min(width, context.meta.width || width),
628
+ height: Math.min(height ?? width, (context.meta.height || height) ?? width),
629
+ ...maxSlope === void 0 ? {} : { maxSlope }
630
+ });
631
+ }
632
+ };
633
+ const flatten = {
634
+ args: [],
259
635
  apply: (context, pipe) => {
260
636
  return pipe.flatten({ background: context.background });
261
637
  }
262
638
  };
639
+ const unflatten = {
640
+ args: [],
641
+ apply: (_context, pipe) => {
642
+ return pipe.unflatten();
643
+ }
644
+ };
263
645
  const gamma = {
264
- args: [
265
- VArg,
266
- VArg,
267
- VArg
268
- ],
646
+ args: [VNumber("gamma", {
647
+ min: 1,
648
+ max: 3
649
+ }), VNumber("gamma.gammaOut", {
650
+ min: 1,
651
+ max: 3
652
+ })],
269
653
  apply: (_context, pipe, gamma, gammaOut) => {
270
654
  return pipe.gamma(gamma, gammaOut);
271
655
  }
272
656
  };
273
657
  const negate = {
274
- args: [
275
- VArg,
276
- VArg,
277
- VArg
278
- ],
279
- apply: (_context, pipe) => {
280
- return pipe.negate();
658
+ args: [VBoolean("negate.alpha")],
659
+ apply: (_context, pipe, alpha) => {
660
+ return alpha === void 0 ? pipe.negate() : pipe.negate({ alpha });
281
661
  }
282
662
  };
283
663
  const normalize = {
284
- args: [
285
- VArg,
286
- VArg,
287
- VArg
288
- ],
289
- apply: (_context, pipe) => {
290
- return pipe.normalize();
664
+ args: [VNumber("normalize.lower", {
665
+ min: 0,
666
+ max: 99
667
+ }), VNumber("normalize.upper", {
668
+ min: 1,
669
+ max: 100
670
+ })],
671
+ apply: (_context, pipe, lower, upper) => {
672
+ return pipe.normalize({
673
+ lower,
674
+ upper
675
+ });
291
676
  }
292
677
  };
293
678
  const threshold = {
294
- args: [VArg],
295
- apply: (_context, pipe, threshold) => {
296
- return pipe.threshold(threshold);
679
+ args: [VNumber("threshold", {
680
+ min: 0,
681
+ max: 255,
682
+ integer: true
683
+ }), VBoolean("threshold.grayscale")],
684
+ apply: (_context, pipe, threshold, grayscale) => {
685
+ return grayscale === void 0 ? pipe.threshold(threshold) : pipe.threshold(threshold, { grayscale });
686
+ }
687
+ };
688
+ const linear = {
689
+ args: [VNumber("linear.a"), VNumber("linear.b")],
690
+ apply: (_context, pipe, a, b) => {
691
+ return pipe.linear(a, b);
297
692
  }
298
693
  };
299
694
  const modulate = {
300
- args: [VArg],
301
- apply: (_context, pipe, brightness, saturation, hue) => {
695
+ args: [
696
+ VNumber("modulate.brightness", { min: 0 }),
697
+ VNumber("modulate.saturation", { min: 0 }),
698
+ VNumber("modulate.hue", { integer: true }),
699
+ VNumber("modulate.lightness")
700
+ ],
701
+ apply: (_context, pipe, brightness, saturation, hue, lightness) => {
302
702
  return pipe.modulate({
303
- brightness,
304
- saturation,
305
- hue
703
+ ...brightness === void 0 ? {} : { brightness },
704
+ ...saturation === void 0 ? {} : { saturation },
705
+ ...hue === void 0 ? {} : { hue },
706
+ ...lightness === void 0 ? {} : { lightness }
306
707
  });
307
708
  }
308
709
  };
710
+ const brightness = {
711
+ args: [VRequired("brightness", VNumber("brightness", { min: 0 }))],
712
+ apply: (_context, pipe, brightness) => {
713
+ return pipe.modulate({ brightness });
714
+ }
715
+ };
716
+ const saturation = {
717
+ args: [VRequired("saturation", VNumber("saturation", { min: 0 }))],
718
+ apply: (_context, pipe, saturation) => {
719
+ return pipe.modulate({ saturation });
720
+ }
721
+ };
722
+ const hue = {
723
+ args: [VRequired("hue", VNumber("hue", { integer: true }))],
724
+ apply: (_context, pipe, hue) => {
725
+ return pipe.modulate({ hue });
726
+ }
727
+ };
728
+ const lightness = {
729
+ args: [VRequired("lightness", VNumber("lightness"))],
730
+ apply: (_context, pipe, lightness) => {
731
+ return pipe.modulate({ lightness });
732
+ }
733
+ };
734
+ const opacity = {
735
+ args: [VRequired("opacity", VNumber("opacity", {
736
+ min: 0,
737
+ max: 1
738
+ }))],
739
+ apply: (context, pipe, opacity) => {
740
+ const overlay = opacityOverlay(opacity, context.background);
741
+ return (context.background ? pipe : pipe.ensureAlpha()).composite([overlay]);
742
+ }
743
+ };
309
744
  const tint = {
310
- args: [VArg],
745
+ args: [VColor("tint")],
311
746
  apply: (_context, pipe, rgb) => {
312
747
  return pipe.tint(rgb);
313
748
  }
314
749
  };
315
750
  const grayscale = {
316
- args: [VArg],
751
+ args: [],
317
752
  apply: (_context, pipe) => {
318
753
  return pipe.grayscale();
319
754
  }
@@ -325,9 +760,99 @@ const w = width;
325
760
  const h = height;
326
761
  const s = resize;
327
762
  const pos = position;
763
+ const UNSAFE_ELEMENTS = /* @__PURE__ */ new Set([
764
+ "script",
765
+ "foreignobject",
766
+ "iframe",
767
+ "embed",
768
+ "object",
769
+ "handler",
770
+ "listener",
771
+ "base",
772
+ "link",
773
+ "meta"
774
+ ]);
775
+ const ANIMATION_ELEMENTS = /* @__PURE__ */ new Set([
776
+ "animate",
777
+ "animatemotion",
778
+ "animatetransform",
779
+ "set"
780
+ ]);
781
+ const ANIMATION_VALUE_ATTRS = [
782
+ "from",
783
+ "to",
784
+ "by",
785
+ "values"
786
+ ];
787
+ const URI_ATTRS = /* @__PURE__ */ new Set(["href", "src"]);
788
+ const SAFE_URI_SCHEMES = /* @__PURE__ */ new Set([
789
+ "http",
790
+ "https",
791
+ "mailto",
792
+ "tel",
793
+ "ftp"
794
+ ]);
795
+ const SAFE_DATA_URI_RE = /^data:image\/(?!svg\+xml)[\w.+-]+[,;]/;
796
+ const URI_SCHEME_RE = /^([a-z][\d+.a-z-]*):/;
797
+ const URI_IGNORED_CHARS_RE = /[\u0000-\u0020]/g;
798
+ function localName(name) {
799
+ const colonIndex = name.indexOf(":");
800
+ return (colonIndex === -1 ? name : name.slice(colonIndex + 1)).toLowerCase();
801
+ }
802
+ function isUnsafeURI(value) {
803
+ const uri = value.replace(URI_IGNORED_CHARS_RE, "").toLowerCase();
804
+ const scheme = URI_SCHEME_RE.exec(uri)?.[1];
805
+ if (!scheme) return false;
806
+ if (scheme === "data") return !SAFE_DATA_URI_RE.test(uri);
807
+ return !SAFE_URI_SCHEMES.has(scheme);
808
+ }
809
+ function isUnsafeAnimation(attributes) {
810
+ const attrs = {};
811
+ for (const [name, value] of Object.entries(attributes)) attrs[localName(name)] = value;
812
+ if (localName(attrs.attributename?.trim() || "").startsWith("on")) return true;
813
+ return ANIMATION_VALUE_ATTRS.some((attr) => (attrs[attr] || "").split(";").some((value) => isUnsafeURI(value)));
814
+ }
815
+ function detach(node, parent) {
816
+ parent.children = parent.children.filter((child) => child !== node);
817
+ }
818
+ const sanitizeSVGPlugin = {
819
+ name: "ipx-sanitize",
820
+ fn: () => ({
821
+ doctype: { enter: (node, parentNode) => detach(node, parentNode) },
822
+ instruction: { enter: (node, parentNode) => detach(node, parentNode) },
823
+ element: { enter: (node, parentNode) => {
824
+ const name = localName(node.name);
825
+ if (UNSAFE_ELEMENTS.has(name)) {
826
+ detach(node, parentNode);
827
+ return;
828
+ }
829
+ if (ANIMATION_ELEMENTS.has(name) && isUnsafeAnimation(node.attributes)) {
830
+ detach(node, parentNode);
831
+ return;
832
+ }
833
+ for (const [attr, value] of Object.entries(node.attributes)) {
834
+ const attrName = localName(attr);
835
+ if (attrName.startsWith("on") || URI_ATTRS.has(attrName) && isUnsafeURI(value)) delete node.attributes[attr];
836
+ }
837
+ } }
838
+ })
839
+ };
328
840
  function getEnv(name) {
329
841
  const value = globalThis.process?.env?.[name];
330
- if (value !== void 0) return JSON.parse(value);
842
+ if (value === void 0 || value.trim() === "") return;
843
+ try {
844
+ return JSON.parse(value);
845
+ } catch {
846
+ return value;
847
+ }
848
+ }
849
+ function normalizeMaxAge(value) {
850
+ const n = typeof value === "string" && /^\d+$/.test(value) ? Number.parseInt(value, 10) : value;
851
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return;
852
+ return Math.min(Math.floor(n), 2 ** 31);
853
+ }
854
+ function getBuiltinModule(id) {
855
+ return globalThis.process?.getBuiltinModule?.(id);
331
856
  }
332
857
  function requireModule(id) {
333
858
  const { createRequire } = globalThis.process.getBuiltinModule("node:module");
@@ -341,6 +866,26 @@ function cachedPromise(function_) {
341
866
  return p;
342
867
  };
343
868
  }
869
+ const DEFAULT_MAX_OUTPUT_DIMENSION = 8192;
870
+ const NON_HANDLER_MODIFIERS = /* @__PURE__ */ new Map([
871
+ ["format", "format"],
872
+ ["f", "format"],
873
+ ["animated", "animated"],
874
+ ["a", "animated"]
875
+ ]);
876
+ function getModifierKey(name) {
877
+ return NON_HANDLER_MODIFIERS.get(name) ?? getHandler(name);
878
+ }
879
+ function resolveAllowedModifiers(names) {
880
+ if (names === void 0) return;
881
+ if (typeof names === "string") names = names.split(",").map((s) => s.trim()).filter(Boolean);
882
+ if (!Array.isArray(names)) throw new TypeError(`[ipx] allowedModifiers must be an array or a comma-separated string, got: ${JSON.stringify(names)}`);
883
+ return new Set(names.map((name) => {
884
+ const key = getModifierKey(name);
885
+ if (!key) throw new Error(`[ipx] Unknown modifier in allowedModifiers: ${name}`);
886
+ return key;
887
+ }));
888
+ }
344
889
  const SUPPORTED_FORMATS = /* @__PURE__ */ new Set([
345
890
  "jpeg",
346
891
  "png",
@@ -355,12 +900,14 @@ function createIPX(userOptions) {
355
900
  const options = {
356
901
  ...userOptions,
357
902
  alias: userOptions.alias || getEnv("IPX_ALIAS") || {},
358
- maxAge: userOptions.maxAge ?? getEnv("IPX_MAX_AGE") ?? 60,
903
+ maxAge: normalizeMaxAge(userOptions.maxAge) ?? normalizeMaxAge(getEnv("IPX_MAX_AGE")) ?? 60,
904
+ maxOutputDimension: userOptions.maxOutputDimension ?? getEnv("IPX_MAX_OUTPUT_DIMENSION") ?? DEFAULT_MAX_OUTPUT_DIMENSION,
359
905
  sharpOptions: {
360
906
  jpegProgressive: true,
361
907
  ...userOptions.sharpOptions
362
908
  }
363
909
  };
910
+ const allowedModifiers = resolveAllowedModifiers(userOptions.allowedModifiers ?? getEnv("IPX_ALLOWED_MODIFIERS"));
364
911
  options.alias = Object.fromEntries(Object.entries(options.alias || {}).map((e) => [withLeadingSlash(e[0]), e[1]]));
365
912
  const getSharp = cachedPromise(async () => {
366
913
  return await import("sharp").then((r) => r.default || r);
@@ -375,6 +922,14 @@ function createIPX(userOptions) {
375
922
  statusText: `IPX_MISSING_ID`,
376
923
  message: `Resource id is missing`
377
924
  });
925
+ modifiers = Object.assign(Object.create(null), modifiers);
926
+ if (allowedModifiers) {
927
+ for (const name of Object.keys(modifiers)) if (!allowedModifiers.has(getModifierKey(name))) throw new HTTPError({
928
+ statusCode: 400,
929
+ statusText: `IPX_FORBIDDEN_MODIFIER`,
930
+ message: `Modifier is not allowed: ${name}`
931
+ });
932
+ }
378
933
  id = hasProtocol(id) ? id : withLeadingSlash(id);
379
934
  for (const base in options.alias) if (id.startsWith(base)) id = joinURL(options.alias[base], id.slice(base.length));
380
935
  const storage = hasProtocol(id) ? options.httpStorage || options.storage : options.storage || options.httpStorage;
@@ -390,9 +945,8 @@ function createIPX(userOptions) {
390
945
  statusText: `IPX_RESOURCE_NOT_FOUND`,
391
946
  message: `Resource not found: ${id}`
392
947
  });
393
- const _maxAge = sourceMeta.maxAge ?? options.maxAge;
394
948
  return {
395
- maxAge: typeof _maxAge === "string" ? Number.parseInt(_maxAge) : _maxAge,
949
+ maxAge: normalizeMaxAge(sourceMeta.maxAge) ?? options.maxAge,
396
950
  mtime: sourceMeta.mtime ? new Date(sourceMeta.mtime) : void 0
397
951
  };
398
952
  });
@@ -421,43 +975,83 @@ function createIPX(userOptions) {
421
975
  }
422
976
  let mFormat = modifiers.f || modifiers.format;
423
977
  if (mFormat === "jpg") mFormat = "jpeg";
424
- const format = mFormat && SUPPORTED_FORMATS.has(mFormat) ? mFormat : SUPPORTED_FORMATS.has(imageMeta$1.type || "") ? imageMeta$1.type : "jpeg";
425
- if (imageMeta$1.type === "svg" && !mFormat) if (options.svgo === false) return {
426
- data: sourceData,
427
- format: "svg+xml",
428
- meta: imageMeta$1
429
- };
430
- else {
978
+ if (imageMeta$1.type === "svg" && (!mFormat || mFormat === "svg")) {
979
+ const sanitize = !options.svg?.unsafeSkipSanitize;
980
+ const shouldOptimize = options.svg?.optimize !== false;
981
+ const svgoConfig = options.svg?.optimize === false ? void 0 : options.svg?.optimize;
982
+ if (!sanitize && !shouldOptimize) return {
983
+ data: sourceData,
984
+ format: "svg+xml",
985
+ meta: imageMeta$1
986
+ };
987
+ const plugins = sanitize ? ["removeScripts", sanitizeSVGPlugin] : [];
988
+ if (svgoConfig?.plugins) plugins.push(...svgoConfig.plugins);
989
+ else if (shouldOptimize) plugins.push("preset-default");
431
990
  const { optimize } = await getSVGO();
991
+ let svg;
992
+ try {
993
+ svg = optimize(sourceData.toString("utf8"), {
994
+ ...svgoConfig,
995
+ plugins
996
+ }).data;
997
+ } catch (error) {
998
+ throw new HTTPError({
999
+ statusCode: 400,
1000
+ statusText: `IPX_INVALID_SVG`,
1001
+ message: `Cannot parse SVG: ${id}`,
1002
+ cause: error
1003
+ });
1004
+ }
432
1005
  return {
433
- data: optimize(sourceData.toString("utf8"), {
434
- ...options.svgo,
435
- plugins: ["removeScripts", ...options.svgo?.plugins || []]
436
- }).data,
1006
+ data: svg,
437
1007
  format: "svg+xml",
438
1008
  meta: imageMeta$1
439
1009
  };
440
1010
  }
1011
+ const format = mFormat && SUPPORTED_FORMATS.has(mFormat) ? mFormat : SUPPORTED_FORMATS.has(imageMeta$1.type || "") ? imageMeta$1.type : "jpeg";
441
1012
  const animated = modifiers.animated !== void 0 || modifiers.a !== void 0 || format === "gif";
442
- let sharp = (await getSharp())(sourceData, {
1013
+ const Sharp = await getSharp();
1014
+ let sharp = Sharp(sourceData, {
443
1015
  animated,
444
1016
  ...options.sharpOptions
445
1017
  });
446
1018
  Object.assign(sharp.options, options.sharpOptions);
447
- const handlers = Object.entries(modifiers).map(([name, arguments_]) => ({
448
- handler: getHandler(name),
449
- name,
450
- args: arguments_
451
- })).filter((h) => h.handler).sort((a, b) => {
452
- const aKey = (a.handler.order || a.name || "").toString();
453
- const bKey = (b.handler.order || b.name || "").toString();
454
- return aKey.localeCompare(bKey);
1019
+ const handlers = Object.entries(modifiers).flatMap(([name, arguments_]) => {
1020
+ const handler = getHandler(name);
1021
+ return handler ? [{
1022
+ handler,
1023
+ name,
1024
+ args: arguments_
1025
+ }] : [];
1026
+ }).sort((a, b) => {
1027
+ const aOrder = a.handler.order || 0;
1028
+ const bOrder = b.handler.order || 0;
1029
+ return aOrder === bOrder ? (a.name || "").localeCompare(b.name || "") : aOrder - bOrder;
455
1030
  });
456
- const handlerContext = { meta: imageMeta$1 };
1031
+ const handlerContext = {
1032
+ meta: imageMeta$1,
1033
+ maxOutputDimension: options.maxOutputDimension
1034
+ };
457
1035
  for (const h of handlers) sharp = applyHandler(handlerContext, sharp, h.handler, h.args.toString()) || sharp;
1036
+ if (handlerContext.background) try {
1037
+ Sharp({ create: {
1038
+ width: 1,
1039
+ height: 1,
1040
+ channels: 4,
1041
+ background: handlerContext.background
1042
+ } });
1043
+ } catch (error) {
1044
+ throw asModifierError(error);
1045
+ }
458
1046
  if (SUPPORTED_FORMATS.has(format || "")) sharp = sharp.toFormat(format, { quality: handlerContext.quality });
1047
+ let processedImage;
1048
+ try {
1049
+ processedImage = await sharp.toBuffer();
1050
+ } catch (error) {
1051
+ throw asModifierError(error);
1052
+ }
459
1053
  return {
460
- data: await sharp.toBuffer(),
1054
+ data: processedImage,
461
1055
  format,
462
1056
  meta: imageMeta$1
463
1057
  };
@@ -467,46 +1061,62 @@ function createIPX(userOptions) {
467
1061
  }
468
1062
  var import_etag = /* @__PURE__ */ __toESM(require_etag(), 1);
469
1063
  var import_accept_negotiator = require_accept_negotiator();
470
- function createIPXFetchHandler(ipx) {
471
- return createIPXHandler(ipx).fetch;
1064
+ function createIPXFetchHandler(ipx, opts) {
1065
+ return createIPXHandler(ipx, opts).fetch;
472
1066
  }
473
- function createIPXNodeHandler(ipx) {
1067
+ function createIPXNodeHandler(ipx, opts) {
474
1068
  const { toNodeHandler } = requireModule("srvx/node");
475
- return toNodeHandler(createIPXFetchHandler(ipx));
1069
+ return toNodeHandler(createIPXFetchHandler(ipx, opts));
476
1070
  }
477
1071
  function serveIPX(ipx, opts) {
478
1072
  const { serve } = requireModule("srvx");
479
- const fetch = createIPXFetchHandler(ipx);
1073
+ const { parseURL, autoFormats, ...serverOptions } = opts || {};
1074
+ const fetch = createIPXFetchHandler(ipx, {
1075
+ parseURL,
1076
+ autoFormats
1077
+ });
480
1078
  return serve({
481
- ...opts,
1079
+ ...serverOptions,
482
1080
  fetch
483
1081
  });
484
1082
  }
485
1083
  const MODIFIER_SEP = /[&,]/g;
486
1084
  const MODIFIER_VAL_SEP = /[:=_]/;
487
- function createIPXHandler(ipx) {
1085
+ function parseIPXURL(url) {
1086
+ const [modifiersString = "", ...idSegments] = new URL(url).pathname.slice(1).split("/");
1087
+ const id = decode$1(idSegments.join("/"));
1088
+ if (!modifiersString) throw new HTTPError({
1089
+ statusCode: 400,
1090
+ statusText: "IPX_MISSING_MODIFIERS",
1091
+ message: `Modifiers are missing: ${safeString(id)}`
1092
+ });
1093
+ const modifiers = Object.create(null);
1094
+ if (modifiersString !== "_") for (const p of modifiersString.split(MODIFIER_SEP)) {
1095
+ const [key = "", ...values] = p.split(MODIFIER_VAL_SEP);
1096
+ modifiers[key] = values.map((v) => decode$1(v)).join("_");
1097
+ }
1098
+ return {
1099
+ id,
1100
+ modifiers
1101
+ };
1102
+ }
1103
+ function createIPXHandler(ipx, opts = {}) {
1104
+ const parseURL = opts.parseURL || parseIPXURL;
1105
+ const autoFormats = resolveAutoFormats(opts.autoFormats || getEnv("IPX_AUTO_FORMATS"));
488
1106
  return defineEventHandler(async (event) => {
489
- const [modifiersString = "", ...idSegments] = event.url.pathname.slice(1).split("/");
490
- const id = safeString(decode(idSegments.join("/")));
491
- if (!modifiersString) throw new HTTPError({
492
- statusCode: 400,
493
- statusText: "IPX_MISSING_MODIFIERS",
494
- message: `Modifiers are missing: ${id}`
495
- });
1107
+ const parsed = await parseURL(event.req.url) || {};
1108
+ const id = safeString(parsed.id);
496
1109
  if (!id || id === "/") throw new HTTPError({
497
1110
  statusCode: 400,
498
1111
  statusText: "IPX_MISSING_ID",
499
1112
  message: `Resource id is missing: ${event.path}`
500
1113
  });
501
1114
  const modifiers = Object.create(null);
502
- if (modifiersString !== "_") for (const p of modifiersString.split(MODIFIER_SEP)) {
503
- const [key, ...values] = p.split(MODIFIER_VAL_SEP);
504
- modifiers[safeString(key)] = values.map((v) => safeString(decode(v))).join("_");
505
- }
1115
+ for (const [key, value] of Object.entries(parsed.modifiers || {})) modifiers[safeString(key)] = safeString(value);
506
1116
  if ((modifiers.f || modifiers.format) === "auto") {
507
1117
  const acceptHeader = event.req.headers.get("accept") || "";
508
1118
  const animated = modifiers.animated ?? modifiers.a;
509
- const autoFormat = autoDetectFormat(acceptHeader, !!animated || animated === "");
1119
+ const autoFormat = autoDetectFormat(acceptHeader, autoFormats, !!animated || animated === "");
510
1120
  delete modifiers.f;
511
1121
  delete modifiers.format;
512
1122
  if (autoFormat) {
@@ -517,6 +1127,9 @@ function createIPXHandler(ipx) {
517
1127
  const img = ipx(id, modifiers);
518
1128
  const sourceMeta = await img.getSourceMeta();
519
1129
  sendResponseHeaderIfNotSet(event, "content-security-policy", "default-src 'none'");
1130
+ sendResponseHeaderIfNotSet(event, "x-content-type-options", "nosniff");
1131
+ if (typeof sourceMeta.maxAge === "number") sendResponseHeaderIfNotSet(event, "cache-control", `max-age=${+sourceMeta.maxAge}, public, s-maxage=${+sourceMeta.maxAge}`);
1132
+ const ifNoneMatch = event.req.headers.get("if-none-match");
520
1133
  if (sourceMeta.mtime) {
521
1134
  sendResponseHeaderIfNotSet(event, "last-modified", sourceMeta.mtime.toUTCString());
522
1135
  const _ifModifiedSince = event.req.headers.get("if-modified-since");
@@ -525,13 +1138,22 @@ function createIPXHandler(ipx) {
525
1138
  return;
526
1139
  }
527
1140
  }
1141
+ const weakEtag = sourceMeta.mtime ? getWeakEtag(id, modifiers, sourceMeta.mtime) : void 0;
1142
+ if (weakEtag) {
1143
+ sendResponseHeaderIfNotSet(event, "etag", weakEtag);
1144
+ if (etagMatches(ifNoneMatch, weakEtag)) {
1145
+ event.res.status = 304;
1146
+ return;
1147
+ }
1148
+ }
528
1149
  const { data, format } = await img.process();
529
- if (typeof sourceMeta.maxAge === "number") sendResponseHeaderIfNotSet(event, "cache-control", `max-age=${+sourceMeta.maxAge}, public, s-maxage=${+sourceMeta.maxAge}`);
530
- const etag = (0, import_etag.default)(data);
531
- sendResponseHeaderIfNotSet(event, "etag", etag);
532
- if (etag && event.req.headers.get("if-none-match") === etag) {
533
- event.res.status = 304;
534
- return;
1150
+ if (!weakEtag) {
1151
+ const etag = (0, import_etag.default)(data);
1152
+ sendResponseHeaderIfNotSet(event, "etag", etag);
1153
+ if (etagMatches(ifNoneMatch, etag)) {
1154
+ event.res.status = 304;
1155
+ return;
1156
+ }
535
1157
  }
536
1158
  if (format) sendResponseHeaderIfNotSet(event, "content-type", `image/${format}`);
537
1159
  return data;
@@ -540,54 +1162,295 @@ function createIPXHandler(ipx) {
540
1162
  function sendResponseHeaderIfNotSet(event, name, value) {
541
1163
  if (!event.res.headers.has(name)) event.res.headers.set(name, value);
542
1164
  }
543
- function autoDetectFormat(acceptHeader, animated) {
544
- if (animated) return (0, import_accept_negotiator.negotiate)(acceptHeader, ["image/webp", "image/gif"])?.split("/")[1] || "gif";
545
- return (0, import_accept_negotiator.negotiate)(acceptHeader, [
546
- "image/avif",
547
- "image/webp",
548
- "image/jpeg",
549
- "image/png",
550
- "image/tiff",
551
- "image/heif",
552
- "image/gif"
553
- ])?.split("/")[1] || "jpeg";
1165
+ function getWeakEtag(id, modifiers, mtime) {
1166
+ const crypto = getBuiltinModule("node:crypto");
1167
+ if (!crypto?.createHash) return;
1168
+ const key = JSON.stringify([
1169
+ mtime.getTime(),
1170
+ id,
1171
+ Object.keys(modifiers).sort().map((key) => [key, modifiers[key]])
1172
+ ]);
1173
+ return `W/"${crypto.createHash("sha256").update(key).digest("base64url").slice(0, 27)}"`;
1174
+ }
1175
+ function etagMatches(ifNoneMatch, etag) {
1176
+ if (!ifNoneMatch || !etag) return false;
1177
+ if (ifNoneMatch === "*") return true;
1178
+ const opaque = opaqueTag(etag);
1179
+ return ifNoneMatch.split(",").some((tag) => opaqueTag(tag.trim()) === opaque);
1180
+ }
1181
+ function opaqueTag(tag) {
1182
+ return tag.startsWith("W/") ? tag.slice(2) : tag;
1183
+ }
1184
+ const DEFAULT_AUTO_FORMATS = [
1185
+ "avif",
1186
+ "webp",
1187
+ "jpeg",
1188
+ "png",
1189
+ "tiff",
1190
+ "heif",
1191
+ "gif"
1192
+ ];
1193
+ const ANIMATED_FORMATS = /* @__PURE__ */ new Set(["webp", "gif"]);
1194
+ function resolveAutoFormats(input) {
1195
+ let list = [];
1196
+ if (typeof input === "string") list = input.split(",");
1197
+ else if (Array.isArray(input)) list = input;
1198
+ const formats = list.map((f) => String(f ?? "").trim().toLowerCase()).filter(Boolean).map((f) => f === "jpg" ? "jpeg" : f);
1199
+ const invalid = formats.filter((f) => !SUPPORTED_FORMATS.has(f));
1200
+ if (invalid.length > 0) throw new TypeError(`[ipx] Unsupported \`autoFormats\`: ${invalid.join(", ")} (supported: ${[...SUPPORTED_FORMATS].join(", ")})`);
1201
+ if (formats.length === 0) formats.push(...DEFAULT_AUTO_FORMATS);
1202
+ return {
1203
+ mimes: formats.map((f) => `image/${f}`),
1204
+ animatedMimes: formats.filter((f) => ANIMATED_FORMATS.has(f)).map((f) => `image/${f}`)
1205
+ };
1206
+ }
1207
+ function autoDetectFormat(acceptHeader, autoFormats, animated) {
1208
+ if (animated) return (0, import_accept_negotiator.negotiate)(acceptHeader, autoFormats.animatedMimes)?.split("/")[1] || "gif";
1209
+ return (0, import_accept_negotiator.negotiate)(acceptHeader, autoFormats.mimes)?.split("/")[1] || "jpeg";
1210
+ }
1211
+ function decode$1(input) {
1212
+ try {
1213
+ return decodeURIComponent(input);
1214
+ } catch {
1215
+ return input;
1216
+ }
554
1217
  }
555
1218
  function safeString(input) {
556
- return JSON.stringify(input).replace(/^"|"$/g, "").replace(/\\+/g, "\\").replace(/\\"/g, "\"");
1219
+ return JSON.stringify(input ?? "").replace(/^"|"$/g, "").replace(/\\+/g, "\\").replace(/\\"/g, "\"");
557
1220
  }
558
1221
  const HTTP_RE = /^https?:\/\//;
1222
+ const REDIRECT_STATUS = /* @__PURE__ */ new Set([
1223
+ 301,
1224
+ 302,
1225
+ 303,
1226
+ 307,
1227
+ 308
1228
+ ]);
1229
+ const MAX_REDIRECTS = 3;
1230
+ function parseIPv4(input) {
1231
+ const parts = input.split(".");
1232
+ if (parts.length !== 4) return;
1233
+ const octets = [];
1234
+ for (const part of parts) {
1235
+ if (!/^\d{1,3}$/.test(part)) return;
1236
+ const octet = Number(part);
1237
+ if (octet > 255) return;
1238
+ octets.push(octet);
1239
+ }
1240
+ return octets;
1241
+ }
1242
+ function parseIPv6(input) {
1243
+ const zoneIndex = input.indexOf("%");
1244
+ const halves = (zoneIndex === -1 ? input : input.slice(0, zoneIndex)).split("::");
1245
+ if (halves.length > 2) return;
1246
+ const expand = (part) => {
1247
+ if (part === "") return [];
1248
+ const bytes = [];
1249
+ const groups = part.split(":");
1250
+ for (const [index, group] of groups.entries()) {
1251
+ if (group.includes(".")) {
1252
+ if (index !== groups.length - 1) return;
1253
+ const octets = parseIPv4(group);
1254
+ if (!octets) return;
1255
+ bytes.push(...octets);
1256
+ continue;
1257
+ }
1258
+ if (!/^[\da-f]{1,4}$/i.test(group)) return;
1259
+ const value = Number.parseInt(group, 16);
1260
+ bytes.push(value >> 8, value & 255);
1261
+ }
1262
+ return bytes;
1263
+ };
1264
+ const head = expand(halves[0]);
1265
+ const tail = halves.length === 2 ? expand(halves[1]) : [];
1266
+ if (!head || !tail) return;
1267
+ if (halves.length === 1) return head.length === 16 ? head : void 0;
1268
+ const fill = 16 - head.length - tail.length;
1269
+ if (fill < 0) return;
1270
+ return [
1271
+ ...head,
1272
+ ...Array.from({ length: fill }, () => 0),
1273
+ ...tail
1274
+ ];
1275
+ }
1276
+ function embeddedIPv4(bytes) {
1277
+ if (bytes.slice(0, 10).every((byte) => byte === 0) && (bytes[10] === 255 && bytes[11] === 255 || bytes[10] === 0 && bytes[11] === 0)) return bytes.slice(12);
1278
+ if (bytes.slice(0, 8).every((byte) => byte === 0) && bytes[8] === 255 && bytes[9] === 255 && bytes[10] === 0 && bytes[11] === 0) return bytes.slice(12);
1279
+ if (bytes[0] === 0 && bytes[1] === 100 && bytes[2] === 255 && bytes[3] === 155 && bytes.slice(4, 12).every((byte) => byte === 0)) return bytes.slice(12);
1280
+ if (bytes[0] === 32 && bytes[1] === 2) return bytes.slice(2, 6);
1281
+ }
1282
+ function isPublicIPv4(octets) {
1283
+ const [a, b, c] = octets;
1284
+ return !(a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 0 && c === 0 || a === 192 && b === 0 && c === 2 || a === 192 && b === 88 && c === 99 || a === 192 && b === 168 || a === 198 && (b === 18 || b === 19) || a === 198 && b === 51 && c === 100 || a === 203 && b === 0 && c === 113 || a >= 224);
1285
+ }
1286
+ function isPublicIPv6(bytes) {
1287
+ const embedded = embeddedIPv4(bytes);
1288
+ if (embedded) return isPublicIPv4(embedded);
1289
+ const [a, b] = bytes;
1290
+ return !(a === 255 || a === 254 && (b & 192) === 128 || a === 254 && (b & 192) === 192 || (a & 254) === 252 || a === 0 && b === 100 && bytes[2] === 255 && bytes[3] === 155 || a === 1 && b === 0 && bytes.slice(2, 8).every((x) => x === 0) || a === 32 && b === 1 && bytes[2] === 13 && bytes[3] === 184 || a === 32 && b === 1 && bytes[2] === 0 && bytes[3] === 0);
1291
+ }
1292
+ function isPublicIP(address, family) {
1293
+ if (family === 4) {
1294
+ const octets = parseIPv4(address);
1295
+ return octets ? isPublicIPv4(octets) : false;
1296
+ }
1297
+ if (family === 6) {
1298
+ const bytes = parseIPv6(address);
1299
+ return bytes ? isPublicIPv6(bytes) : false;
1300
+ }
1301
+ return false;
1302
+ }
1303
+ function decode(input) {
1304
+ try {
1305
+ return decodeURIComponent(input);
1306
+ } catch {
1307
+ return input;
1308
+ }
1309
+ }
1310
+ function parseCacheControlMaxAge(header) {
1311
+ let start = 0;
1312
+ let quoted = false;
1313
+ for (let i = 0; i <= header.length; i++) {
1314
+ const char = header[i];
1315
+ if (quoted && char === "\\") {
1316
+ i++;
1317
+ continue;
1318
+ }
1319
+ if (char === "\"") quoted = !quoted;
1320
+ if (!quoted && (char === "," || i === header.length)) {
1321
+ const m = header.slice(start, i).trim().match(/^max-age=(?:"(\d+)"|(\d+))$/i);
1322
+ const age = m?.[1] ?? m?.[2];
1323
+ if (age !== void 0) return normalizeMaxAge(age);
1324
+ start = i + 1;
1325
+ }
1326
+ }
1327
+ }
559
1328
  function ipxHttpStorage(_options = {}) {
560
1329
  const allowAllDomains = _options.allowAllDomains ?? getEnv("IPX_HTTP_ALLOW_ALL_DOMAINS") ?? false;
561
1330
  let _domains = _options.domains || getEnv("IPX_HTTP_DOMAINS") || [];
562
- const defaultMaxAge = _options.maxAge || getEnv("IPX_HTTP_MAX_AGE") || 300;
1331
+ const defaultMaxAge = normalizeMaxAge(_options.maxAge) ?? normalizeMaxAge(getEnv("IPX_HTTP_MAX_AGE")) ?? 300;
563
1332
  const fetchOptions = _options.fetchOptions || getEnv("IPX_HTTP_FETCH_OPTIONS") || {};
1333
+ const blockPrivateIPs = _options.blockPrivateIPs ?? getEnv("IPX_HTTP_BLOCK_PRIVATE_IPS") ?? false;
564
1334
  if (typeof _domains === "string") _domains = _domains.split(",").map((s) => s.trim());
565
1335
  const domains = new Set(_domains.map((d) => {
566
1336
  if (!HTTP_RE.test(d)) d = "http://" + d;
567
1337
  return new URL(d).hostname;
568
1338
  }).filter(Boolean));
569
- function validateId(id) {
570
- const url = new URL(decodeURIComponent(id));
1339
+ async function validateId(id) {
1340
+ let url;
1341
+ try {
1342
+ url = new URL(decode(id));
1343
+ } catch {
1344
+ throw new HTTPError({
1345
+ statusCode: 400,
1346
+ statusText: `IPX_INVALID_URL`,
1347
+ message: `Invalid URL: ${id}`
1348
+ });
1349
+ }
1350
+ await validateURL(url, id);
1351
+ return url.toString();
1352
+ }
1353
+ async function validateURL(url, id = url.toString()) {
571
1354
  if (!url.hostname) throw new HTTPError({
572
1355
  statusCode: 403,
573
1356
  statusText: `IPX_MISSING_HOSTNAME`,
574
1357
  message: `Hostname is missing: ${id}`
575
1358
  });
1359
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new HTTPError({
1360
+ statusCode: 403,
1361
+ statusText: `IPX_FORBIDDEN_PROTOCOL`,
1362
+ message: `Forbidden protocol: ${url.protocol}`
1363
+ });
576
1364
  if (!allowAllDomains && !domains.has(url.hostname)) throw new HTTPError({
577
1365
  statusCode: 403,
578
1366
  statusText: `IPX_FORBIDDEN_HOST`,
579
1367
  message: `Forbidden host: ${url.hostname}`
580
1368
  });
581
- return url.toString();
1369
+ if (blockPrivateIPs) await validatePublicIP(url, id);
1370
+ }
1371
+ async function validatePublicIP(url, id) {
1372
+ const hostname = url.hostname.replace(/^\[|]$/g, "");
1373
+ const net = getBuiltinModule("node:net");
1374
+ const dns = getBuiltinModule("node:dns");
1375
+ if (!net?.isIP || !dns?.promises?.lookup) throw new HTTPError({
1376
+ statusCode: 500,
1377
+ statusText: `IPX_IP_CHECK_UNAVAILABLE`,
1378
+ message: `Cannot verify the address of ${hostname}: \`blockPrivateIPs\` requires \`node:net\` and \`node:dns\`.`
1379
+ });
1380
+ const assertPublic = (address, family) => {
1381
+ if (!isPublicIP(address, family)) throw new HTTPError({
1382
+ statusCode: 403,
1383
+ statusText: `IPX_FORBIDDEN_IP`,
1384
+ message: `Forbidden IP address: ${address} (${id})`
1385
+ });
1386
+ };
1387
+ const family = net.isIP(hostname);
1388
+ if (family) {
1389
+ assertPublic(hostname, family);
1390
+ return;
1391
+ }
1392
+ let addresses;
1393
+ try {
1394
+ addresses = await dns.promises.lookup(hostname, {
1395
+ all: true,
1396
+ verbatim: true
1397
+ });
1398
+ } catch {
1399
+ throw new HTTPError({
1400
+ statusCode: 502,
1401
+ statusText: `IPX_DNS_LOOKUP_FAILED`,
1402
+ message: `Cannot resolve host: ${hostname}`
1403
+ });
1404
+ }
1405
+ if (addresses.length === 0) throw new HTTPError({
1406
+ statusCode: 502,
1407
+ statusText: `IPX_DNS_LOOKUP_FAILED`,
1408
+ message: `Cannot resolve host: ${hostname}`
1409
+ });
1410
+ for (const { address, family } of addresses) assertPublic(address, family || net.isIP(address));
1411
+ }
1412
+ async function fetchURL(url, init) {
1413
+ const _init = {
1414
+ ...fetchOptions,
1415
+ ...init
1416
+ };
1417
+ if (allowAllDomains && !blockPrivateIPs || _init.redirect) return fetch(url, _init);
1418
+ let currentURL = url;
1419
+ let method = _init.method || "GET";
1420
+ for (let i = 0; i <= MAX_REDIRECTS; i++) {
1421
+ const response = await fetch(currentURL, {
1422
+ ..._init,
1423
+ method,
1424
+ redirect: "manual"
1425
+ });
1426
+ if (!REDIRECT_STATUS.has(response.status)) return response;
1427
+ const location = response.headers.get("location");
1428
+ if (!location) return response;
1429
+ let nextURL;
1430
+ try {
1431
+ nextURL = new URL(location, currentURL);
1432
+ } catch {
1433
+ throw new HTTPError({
1434
+ statusCode: 502,
1435
+ statusText: `IPX_INVALID_REDIRECT`,
1436
+ message: `Invalid redirect location: ${location}`
1437
+ });
1438
+ }
1439
+ await validateURL(nextURL, location);
1440
+ if (response.status === 303 && method !== "HEAD") method = "GET";
1441
+ currentURL = nextURL.toString();
1442
+ }
1443
+ throw new HTTPError({
1444
+ statusCode: 502,
1445
+ statusText: `IPX_TOO_MANY_REDIRECTS`,
1446
+ message: `Too many redirects (max ${MAX_REDIRECTS}): ${url}`
1447
+ });
582
1448
  }
583
1449
  function parseResponse(response) {
584
1450
  let maxAge = defaultMaxAge;
585
1451
  if (_options.ignoreCacheControl !== true) {
586
1452
  const _cacheControl = response.headers.get("cache-control");
587
- if (_cacheControl) {
588
- const m = _cacheControl.match(/max-age=(\d+)/);
589
- if (m && m[1]) maxAge = Number.parseInt(m[1]);
590
- }
1453
+ if (_cacheControl) maxAge = parseCacheControlMaxAge(_cacheControl) ?? defaultMaxAge;
591
1454
  }
592
1455
  let mtime;
593
1456
  const _lastModified = response.headers.get("last-modified");
@@ -600,12 +1463,9 @@ function ipxHttpStorage(_options = {}) {
600
1463
  return {
601
1464
  name: "ipx:http",
602
1465
  async getMeta(id) {
603
- const url = validateId(id);
1466
+ const url = await validateId(id);
604
1467
  try {
605
- const response = await fetch(url, {
606
- ...fetchOptions,
607
- method: "HEAD"
608
- });
1468
+ const response = await fetchURL(url, { method: "HEAD" });
609
1469
  if (!response.ok) return {};
610
1470
  const { maxAge, mtime } = parseResponse(response);
611
1471
  return {
@@ -617,8 +1477,7 @@ function ipxHttpStorage(_options = {}) {
617
1477
  }
618
1478
  },
619
1479
  async getData(id) {
620
- const url = validateId(id);
621
- const response = await fetch(url, fetchOptions);
1480
+ const response = await fetchURL(await validateId(id));
622
1481
  if (!response.ok) throw new HTTPError({
623
1482
  statusCode: response.status,
624
1483
  statusText: response.statusText,
@@ -628,33 +1487,66 @@ function ipxHttpStorage(_options = {}) {
628
1487
  }
629
1488
  };
630
1489
  }
1490
+ const { join, parse, resolve, relative, isAbsolute, sep } = /* #__PURE__ */ getBuiltinModule("node:path") || {};
1491
+ const NOT_IN_THIS_DIR = /* @__PURE__ */ new Set([
1492
+ "ENOENT",
1493
+ "ENOTDIR",
1494
+ "ELOOP",
1495
+ "ENAMETOOLONG"
1496
+ ]);
631
1497
  function ipxFSStorage(_options = {}) {
1498
+ const fs = getBuiltinModule("node:fs/promises");
1499
+ if (!fs) throw new Error("ipxFSStorage requires the `node:fs/promises` builtin, which this runtime does not provide");
632
1500
  const dirs = resolveDirs(_options.dir);
633
- const maxAge = _options.maxAge || getEnv("IPX_FS_MAX_AGE");
634
- const fs = globalThis.process.getBuiltinModule("node:fs/promises");
1501
+ const maxAge = normalizeMaxAge(_options.maxAge) ?? normalizeMaxAge(getEnv("IPX_FS_MAX_AGE"));
1502
+ const allowSymlinksOutsideDir = _options.allowSymlinksOutsideDir ?? getEnv("IPX_FS_ALLOW_SYMLINKS_OUTSIDE_DIR") ?? false;
1503
+ const realDirCache = [];
1504
+ const getRealDir = (index, dir) => {
1505
+ let realDir = realDirCache[index];
1506
+ if (!realDir) {
1507
+ realDir = fs.realpath(dir).catch(() => dir);
1508
+ realDirCache[index] = realDir;
1509
+ }
1510
+ return realDir;
1511
+ };
635
1512
  const resolveFile = async (id) => {
636
- for (const dir of dirs) {
1513
+ for (const [index, dir] of dirs.entries()) {
637
1514
  const filePath = join(dir, id);
638
- if (!isValidPath(filePath) || !filePath.startsWith(dir + "/")) throw new HTTPError({
1515
+ if (!isValidPath(filePath) || !isInsideDir(filePath, dir)) throw new HTTPError({
639
1516
  statusCode: 403,
640
1517
  statusText: `IPX_FORBIDDEN_PATH`,
641
1518
  message: `Forbidden path: ${id}`
642
1519
  });
1520
+ let stats;
1521
+ let realPath;
643
1522
  try {
644
- const stats = await fs.stat(filePath);
645
- if (!stats.isFile()) continue;
646
- return {
647
- stats,
648
- read: () => fs.readFile(filePath)
649
- };
1523
+ stats = await fs.stat(filePath);
1524
+ if (stats.isFile() && !allowSymlinksOutsideDir) realPath = await fs.realpath(filePath);
650
1525
  } catch (error) {
651
- if (error.code === "ENOENT") continue;
1526
+ if (NOT_IN_THIS_DIR.has(error.code)) continue;
652
1527
  throw new HTTPError({
653
1528
  statusCode: 403,
654
1529
  statusText: `IPX_FORBIDDEN_FILE`,
655
1530
  message: `Cannot access file: ${id}`
656
1531
  });
657
1532
  }
1533
+ if (!stats.isFile()) continue;
1534
+ if (realPath !== void 0) {
1535
+ let realDir = await getRealDir(index, dir);
1536
+ if (!isInsideDir(realPath, realDir)) {
1537
+ realDirCache[index] = void 0;
1538
+ realDir = await getRealDir(index, dir);
1539
+ }
1540
+ if (!isInsideDir(realPath, realDir)) throw new HTTPError({
1541
+ statusCode: 403,
1542
+ statusText: `IPX_FORBIDDEN_SYMLINK`,
1543
+ message: `Forbidden symlink: ${id}`
1544
+ });
1545
+ }
1546
+ return {
1547
+ stats,
1548
+ read: () => fs.readFile(realPath ?? filePath)
1549
+ };
658
1550
  }
659
1551
  throw new HTTPError({
660
1552
  statusCode: 404,
@@ -677,14 +1569,22 @@ function ipxFSStorage(_options = {}) {
677
1569
  }
678
1570
  };
679
1571
  }
680
- const isWindows = process.platform === "win32";
1572
+ const isWindows = globalThis.process?.platform === "win32";
681
1573
  function isValidPath(fp) {
682
1574
  if (isWindows) fp = fp.slice(parse(fp).root.length);
683
1575
  if (/["*:<>?|]/.test(fp)) return false;
684
1576
  return true;
685
1577
  }
1578
+ function isInsideDir(filePath, dir) {
1579
+ if (isWindows) {
1580
+ filePath = filePath.toLowerCase();
1581
+ dir = dir.toLowerCase();
1582
+ }
1583
+ const rel = relative(dir, filePath);
1584
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
1585
+ }
686
1586
  function resolveDirs(dirs) {
687
1587
  if (!dirs || !Array.isArray(dirs)) return [resolve(dirs || getEnv("IPX_FS_DIR") || ".")];
688
1588
  return dirs.map((dirs) => resolve(dirs));
689
1589
  }
690
- export { createIPX, createIPXFetchHandler, createIPXNodeHandler, ipxFSStorage, ipxHttpStorage, serveIPX };
1590
+ export { createIPX, createIPXFetchHandler, createIPXNodeHandler, ipxFSStorage, ipxHttpStorage, parseIPXURL, serveIPX };