ipx 4.0.0-beta.1 → 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.
@@ -91,7 +91,7 @@ function parseArgs(arguments_, mappers) {
91
91
  return mappers.map((v, index) => v(vargs[index]));
92
92
  }
93
93
  function getHandler(key) {
94
- return handlers_exports[key];
94
+ return Object.hasOwn(handlers_exports, key) ? handlers_exports[key] : void 0;
95
95
  }
96
96
  function applyHandler(context, pipe, handler, argumentsString) {
97
97
  const arguments_ = handler.args ? parseArgs(argumentsString, handler.args) : [];
@@ -187,21 +187,22 @@ function clampEdges(available, a, b) {
187
187
  const clamp = (value) => value === void 0 ? void 0 : Math.floor(value * scale);
188
188
  return [clamp(a), clamp(b)];
189
189
  }
190
- function clampDimensionsPreservingAspectRatio(sourceDimensions, desiredDimensions) {
191
- const desiredAspectRatio = desiredDimensions.width / desiredDimensions.height;
192
- let { width, height } = desiredDimensions;
193
- if (sourceDimensions.width && width > sourceDimensions.width) {
194
- width = sourceDimensions.width;
195
- height = Math.round(sourceDimensions.width / desiredAspectRatio);
196
- }
197
- if (sourceDimensions.height && height > sourceDimensions.height) {
198
- height = sourceDimensions.height;
199
- width = Math.round(sourceDimensions.height * desiredAspectRatio);
200
- }
201
- return {
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 {
202
194
  width,
203
195
  height
204
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
+ };
205
206
  }
206
207
  var handlers_exports = /* @__PURE__ */ __exportAll({
207
208
  autoorient: () => autoorient,
@@ -330,9 +331,9 @@ const background = {
330
331
  }
331
332
  };
332
333
  const enlarge = {
333
- args: [],
334
- apply: (context) => {
335
- context.enlarge = true;
334
+ args: [VBoolean("enlarge")],
335
+ apply: (context, _pipe, enabled) => {
336
+ context.enlarge = enabled !== false;
336
337
  }
337
338
  };
338
339
  const kernel = {
@@ -379,11 +380,13 @@ const resize = {
379
380
  apply: (context, pipe, size) => {
380
381
  if (!size) return;
381
382
  let { width, height } = size;
382
- if (!context.enlarge) {
383
+ const keepsSourceAspectRatio = context.fit === "inside" || context.fit === "outside";
384
+ const withoutEnlargement = !context.enlarge && keepsSourceAspectRatio;
385
+ if (!context.enlarge && !keepsSourceAspectRatio) {
383
386
  const clamped = clampDimensionsPreservingAspectRatio(context.meta, {
384
387
  width,
385
388
  height
386
- });
389
+ }, context.fit);
387
390
  width = clamped.width;
388
391
  height = clamped.height;
389
392
  }
@@ -391,12 +394,16 @@ const resize = {
391
394
  width,
392
395
  height
393
396
  });
394
- context.outputDimensions = resizeOutputDimensions(capped, context.meta, { fit: context.fit });
397
+ context.outputDimensions = resizeOutputDimensions(capped, context.meta, {
398
+ fit: context.fit,
399
+ withoutEnlargement
400
+ });
395
401
  return pipe.resize(capped.width, capped.height, {
396
402
  fit: context.fit,
397
403
  position: context.position,
398
404
  background: context.background,
399
- kernel: context.kernel
405
+ kernel: context.kernel,
406
+ withoutEnlargement
400
407
  });
401
408
  }
402
409
  };
@@ -832,13 +839,18 @@ const sanitizeSVGPlugin = {
832
839
  };
833
840
  function getEnv(name) {
834
841
  const value = globalThis.process?.env?.[name];
835
- if (value === void 0) return;
842
+ if (value === void 0 || value.trim() === "") return;
836
843
  try {
837
844
  return JSON.parse(value);
838
845
  } catch {
839
846
  return value;
840
847
  }
841
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
+ }
842
854
  function getBuiltinModule(id) {
843
855
  return globalThis.process?.getBuiltinModule?.(id);
844
856
  }
@@ -855,6 +867,25 @@ function cachedPromise(function_) {
855
867
  };
856
868
  }
857
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
+ }
858
889
  const SUPPORTED_FORMATS = /* @__PURE__ */ new Set([
859
890
  "jpeg",
860
891
  "png",
@@ -869,13 +900,14 @@ function createIPX(userOptions) {
869
900
  const options = {
870
901
  ...userOptions,
871
902
  alias: userOptions.alias || getEnv("IPX_ALIAS") || {},
872
- maxAge: userOptions.maxAge ?? getEnv("IPX_MAX_AGE") ?? 60,
903
+ maxAge: normalizeMaxAge(userOptions.maxAge) ?? normalizeMaxAge(getEnv("IPX_MAX_AGE")) ?? 60,
873
904
  maxOutputDimension: userOptions.maxOutputDimension ?? getEnv("IPX_MAX_OUTPUT_DIMENSION") ?? DEFAULT_MAX_OUTPUT_DIMENSION,
874
905
  sharpOptions: {
875
906
  jpegProgressive: true,
876
907
  ...userOptions.sharpOptions
877
908
  }
878
909
  };
910
+ const allowedModifiers = resolveAllowedModifiers(userOptions.allowedModifiers ?? getEnv("IPX_ALLOWED_MODIFIERS"));
879
911
  options.alias = Object.fromEntries(Object.entries(options.alias || {}).map((e) => [withLeadingSlash(e[0]), e[1]]));
880
912
  const getSharp = cachedPromise(async () => {
881
913
  return await import("sharp").then((r) => r.default || r);
@@ -890,6 +922,14 @@ function createIPX(userOptions) {
890
922
  statusText: `IPX_MISSING_ID`,
891
923
  message: `Resource id is missing`
892
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
+ }
893
933
  id = hasProtocol(id) ? id : withLeadingSlash(id);
894
934
  for (const base in options.alias) if (id.startsWith(base)) id = joinURL(options.alias[base], id.slice(base.length));
895
935
  const storage = hasProtocol(id) ? options.httpStorage || options.storage : options.storage || options.httpStorage;
@@ -905,9 +945,8 @@ function createIPX(userOptions) {
905
945
  statusText: `IPX_RESOURCE_NOT_FOUND`,
906
946
  message: `Resource not found: ${id}`
907
947
  });
908
- const _maxAge = sourceMeta.maxAge ?? options.maxAge;
909
948
  return {
910
- maxAge: typeof _maxAge === "string" ? Number.parseInt(_maxAge) : _maxAge,
949
+ maxAge: normalizeMaxAge(sourceMeta.maxAge) ?? options.maxAge,
911
950
  mtime: sourceMeta.mtime ? new Date(sourceMeta.mtime) : void 0
912
951
  };
913
952
  });
@@ -977,11 +1016,14 @@ function createIPX(userOptions) {
977
1016
  ...options.sharpOptions
978
1017
  });
979
1018
  Object.assign(sharp.options, options.sharpOptions);
980
- const handlers = Object.entries(modifiers).map(([name, arguments_]) => ({
981
- handler: getHandler(name),
982
- name,
983
- args: arguments_
984
- })).filter((h) => h.handler).sort((a, b) => {
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) => {
985
1027
  const aOrder = a.handler.order || 0;
986
1028
  const bOrder = b.handler.order || 0;
987
1029
  return aOrder === bOrder ? (a.name || "").localeCompare(b.name || "") : aOrder - bOrder;
@@ -1028,8 +1070,11 @@ function createIPXNodeHandler(ipx, opts) {
1028
1070
  }
1029
1071
  function serveIPX(ipx, opts) {
1030
1072
  const { serve } = requireModule("srvx");
1031
- const { parseURL, ...serverOptions } = opts || {};
1032
- const fetch = createIPXFetchHandler(ipx, { parseURL });
1073
+ const { parseURL, autoFormats, ...serverOptions } = opts || {};
1074
+ const fetch = createIPXFetchHandler(ipx, {
1075
+ parseURL,
1076
+ autoFormats
1077
+ });
1033
1078
  return serve({
1034
1079
  ...serverOptions,
1035
1080
  fetch
@@ -1057,6 +1102,7 @@ function parseIPXURL(url) {
1057
1102
  }
1058
1103
  function createIPXHandler(ipx, opts = {}) {
1059
1104
  const parseURL = opts.parseURL || parseIPXURL;
1105
+ const autoFormats = resolveAutoFormats(opts.autoFormats || getEnv("IPX_AUTO_FORMATS"));
1060
1106
  return defineEventHandler(async (event) => {
1061
1107
  const parsed = await parseURL(event.req.url) || {};
1062
1108
  const id = safeString(parsed.id);
@@ -1070,7 +1116,7 @@ function createIPXHandler(ipx, opts = {}) {
1070
1116
  if ((modifiers.f || modifiers.format) === "auto") {
1071
1117
  const acceptHeader = event.req.headers.get("accept") || "";
1072
1118
  const animated = modifiers.animated ?? modifiers.a;
1073
- const autoFormat = autoDetectFormat(acceptHeader, !!animated || animated === "");
1119
+ const autoFormat = autoDetectFormat(acceptHeader, autoFormats, !!animated || animated === "");
1074
1120
  delete modifiers.f;
1075
1121
  delete modifiers.format;
1076
1122
  if (autoFormat) {
@@ -1135,17 +1181,32 @@ function etagMatches(ifNoneMatch, etag) {
1135
1181
  function opaqueTag(tag) {
1136
1182
  return tag.startsWith("W/") ? tag.slice(2) : tag;
1137
1183
  }
1138
- function autoDetectFormat(acceptHeader, animated) {
1139
- if (animated) return (0, import_accept_negotiator.negotiate)(acceptHeader, ["image/webp", "image/gif"])?.split("/")[1] || "gif";
1140
- return (0, import_accept_negotiator.negotiate)(acceptHeader, [
1141
- "image/avif",
1142
- "image/webp",
1143
- "image/jpeg",
1144
- "image/png",
1145
- "image/tiff",
1146
- "image/heif",
1147
- "image/gif"
1148
- ])?.split("/")[1] || "jpeg";
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";
1149
1210
  }
1150
1211
  function decode$1(input) {
1151
1212
  try {
@@ -1246,10 +1307,28 @@ function decode(input) {
1246
1307
  return input;
1247
1308
  }
1248
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
+ }
1249
1328
  function ipxHttpStorage(_options = {}) {
1250
1329
  const allowAllDomains = _options.allowAllDomains ?? getEnv("IPX_HTTP_ALLOW_ALL_DOMAINS") ?? false;
1251
1330
  let _domains = _options.domains || getEnv("IPX_HTTP_DOMAINS") || [];
1252
- const defaultMaxAge = _options.maxAge || getEnv("IPX_HTTP_MAX_AGE") || 300;
1331
+ const defaultMaxAge = normalizeMaxAge(_options.maxAge) ?? normalizeMaxAge(getEnv("IPX_HTTP_MAX_AGE")) ?? 300;
1253
1332
  const fetchOptions = _options.fetchOptions || getEnv("IPX_HTTP_FETCH_OPTIONS") || {};
1254
1333
  const blockPrivateIPs = _options.blockPrivateIPs ?? getEnv("IPX_HTTP_BLOCK_PRIVATE_IPS") ?? false;
1255
1334
  if (typeof _domains === "string") _domains = _domains.split(",").map((s) => s.trim());
@@ -1371,10 +1450,7 @@ function ipxHttpStorage(_options = {}) {
1371
1450
  let maxAge = defaultMaxAge;
1372
1451
  if (_options.ignoreCacheControl !== true) {
1373
1452
  const _cacheControl = response.headers.get("cache-control");
1374
- if (_cacheControl) {
1375
- const m = _cacheControl.match(/max-age=(\d+)/);
1376
- if (m && m[1]) maxAge = Number.parseInt(m[1]);
1377
- }
1453
+ if (_cacheControl) maxAge = parseCacheControlMaxAge(_cacheControl) ?? defaultMaxAge;
1378
1454
  }
1379
1455
  let mtime;
1380
1456
  const _lastModified = response.headers.get("last-modified");
@@ -1422,7 +1498,7 @@ function ipxFSStorage(_options = {}) {
1422
1498
  const fs = getBuiltinModule("node:fs/promises");
1423
1499
  if (!fs) throw new Error("ipxFSStorage requires the `node:fs/promises` builtin, which this runtime does not provide");
1424
1500
  const dirs = resolveDirs(_options.dir);
1425
- const maxAge = _options.maxAge ?? getEnv("IPX_FS_MAX_AGE");
1501
+ const maxAge = normalizeMaxAge(_options.maxAge) ?? normalizeMaxAge(getEnv("IPX_FS_MAX_AGE"));
1426
1502
  const allowSymlinksOutsideDir = _options.allowSymlinksOutsideDir ?? getEnv("IPX_FS_ALLOW_SYMLINKS_OUTSIDE_DIR") ?? false;
1427
1503
  const realDirCache = [];
1428
1504
  const getRealDir = (index, dir) => {
@@ -5,14 +5,6 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __esmMin = (fn, res, err) => () => {
9
- if (err) throw err[0];
10
- try {
11
- return fn && (res = fn(fn = 0)), res;
12
- } catch (e) {
13
- throw err = [e], e;
14
- }
15
- };
16
8
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
17
9
  var __exportAll = (all, no_symbols) => {
18
10
  let target = {};
@@ -33,10 +25,9 @@ var __copyProps = (to, from, except, desc) => {
33
25
  }
34
26
  return to;
35
27
  };
36
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
28
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
37
29
  value: mod,
38
30
  enumerable: true
39
31
  }) : target, mod));
40
- var __toCommonJS = (mod) => __hasOwnProp.call(mod, "module.exports") ? mod["module.exports"] : __copyProps(__defProp({}, "__esModule", { value: true }), mod);
41
32
  var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
42
- export { __commonJSMin, __esmMin, __exportAll, __require, __toCommonJS, __toESM };
33
+ export { __commonJSMin, __exportAll, __require, __toESM };
package/dist/cli.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createIPX, ipxFSStorage, ipxHttpStorage, serveIPX } from "./_chunks/node-fs.mjs";
3
3
  import { parseArgs } from "node:util";
4
4
  var name = "ipx";
5
- var version = "4.0.0-beta.0";
5
+ var version = "4.0.0";
6
6
  const { positionals, values } = parseArgs({
7
7
  allowPositionals: true,
8
8
  options: {
@@ -39,10 +39,11 @@ switch (command) {
39
39
  process.exit(1);
40
40
  }
41
41
  async function runServe(args) {
42
- await serveIPX(createIPX({
42
+ const ipx = createIPX({
43
43
  storage: ipxFSStorage({ dir: args.dir ?? process.env.IPX_FS_DIR ?? process.cwd() }),
44
44
  httpStorage: ipxHttpStorage({ domains: args.domains ?? process.env.IPX_HTTP_DOMAINS })
45
- }), {
45
+ });
46
+ await serveIPX(ipx, {
46
47
  port: Number(args.port ?? process.env.PORT ?? 3e3),
47
48
  hostname: args.host ?? process.env.HOST ?? "0.0.0.0"
48
49
  }).ready();
package/dist/index.d.mts CHANGED
@@ -145,7 +145,7 @@ interface IPXModifiers {
145
145
  b: string;
146
146
  position: string;
147
147
  pos: string;
148
- enlarge: FlagModifier;
148
+ enlarge: FlagModifier | false | "false";
149
149
  kernel: "nearest" | "cubic" | "mitchell" | "lanczos2" | "lanczos3" | (string & {});
150
150
  trim: number | string;
151
151
  extend: string;
@@ -234,6 +234,25 @@ type IPXOptions = {
234
234
  * @optional
235
235
  */
236
236
  maxOutputDimension?: number | false;
237
+ /**
238
+ * Modifiers that requests are allowed to use. Any other modifier (including
239
+ * an unknown one) is rejected with a `400` before the source is fetched.
240
+ *
241
+ * Every modifier is allowed by default. Restricting them to the ones a site
242
+ * actually uses (for example `["width", "height", "format", "quality"]`)
243
+ * shrinks the attack surface of the public endpoint: expensive operations
244
+ * such as `blur` or `median` cannot be triggered.
245
+ *
246
+ * Aliases follow their modifier: allowing `width` also allows `w`.
247
+ *
248
+ * Only modifier names are checked, not their values: allowing `format`
249
+ * allows every supported output format, including the slow-to-encode `avif`.
250
+ * The same image also stays reachable under many URLs (`?cb=1`, `w_10` vs
251
+ * `w_1e1`, ...), so this does not prevent busting a CDN cache.
252
+ *
253
+ * @optional
254
+ */
255
+ allowedModifiers?: (keyof IPXModifiers)[];
237
256
  /**
238
257
  * A mapping of URL aliases to their corresponding URLs, used to simplify resource identifiers.
239
258
  * @optional
@@ -283,7 +302,7 @@ type IPXOptions = {
283
302
  * @returns {IPX} An IPX processing function configured with the given options. See {@link IPX}.
284
303
  * @throws {Error} If critical options such as storage are missing or incorrectly configured.
285
304
  */
286
- declare function createIPX(userOptions: IPXOptions): IPX;
305
+ export declare function createIPX(userOptions: IPXOptions): IPX;
287
306
  type FetchHandler = (request: Request | string | URL) => Response | Promise<Response>;
288
307
  interface IPXHandlerOptions {
289
308
  /**
@@ -303,10 +322,32 @@ interface IPXHandlerOptions {
303
322
  * @optional
304
323
  */
305
324
  parseURL?: IPXURLParser;
325
+ /**
326
+ * Output formats `f_auto` can pick from, in order of preference.
327
+ *
328
+ * Useful to leave out formats that are slow to encode, such as `avif`.
329
+ *
330
+ * The format the client's `Accept` header lists with the highest q-value wins,
331
+ * and equal q-values go to the earlier entry. Wildcards such as `image/*` do
332
+ * not match a format, so browsers only negotiate the formats they list
333
+ * explicitly (in practice `avif`, `webp` and `png`).
334
+ *
335
+ * Animated images only consider `webp` and `gif` from this list. When nothing
336
+ * matches, `jpeg` (or `gif` for animated images) is used.
337
+ *
338
+ * Unknown formats throw when the handler is created.
339
+ *
340
+ * Can also be set with the `IPX_AUTO_FORMATS` environment variable (JSON array
341
+ * or comma separated list).
342
+ *
343
+ * @default ["avif", "webp", "jpeg", "png", "tiff", "heif", "gif"]
344
+ */
345
+ autoFormats?: IPXAutoFormat[];
306
346
  }
307
- declare function createIPXFetchHandler(ipx: IPX, opts?: IPXHandlerOptions): FetchHandler;
308
- declare function createIPXNodeHandler(ipx: IPX, opts?: IPXHandlerOptions): NodeHttpHandler;
309
- declare function serveIPX(ipx: IPX, opts?: Omit<ServerOptions, "fetch"> & IPXHandlerOptions): Server;
347
+ type IPXAutoFormat = "avif" | "webp" | "jpeg" | "jpg" | "png" | "tiff" | "heif" | "heic" | "gif";
348
+ export declare function createIPXFetchHandler(ipx: IPX, opts?: IPXHandlerOptions): FetchHandler;
349
+ export declare function createIPXNodeHandler(ipx: IPX, opts?: IPXHandlerOptions): NodeHttpHandler;
350
+ export declare function serveIPX(ipx: IPX, opts?: Omit<ServerOptions, "fetch"> & IPXHandlerOptions): Server;
310
351
  interface IPXParsedURL {
311
352
  /**
312
353
  * The identifier of the source image (path or URL, depending on the storage).
@@ -330,7 +371,7 @@ type IPXURLParser = (url: string) => IPXParsedURL | Promise<IPXParsedURL>;
330
371
  * @returns {IPXParsedURL} The parsed resource id and modifiers. See {@link IPXParsedURL}.
331
372
  * @throws {HTTPError} If the modifiers segment is missing.
332
373
  */
333
- declare function parseIPXURL(url: string): IPXParsedURL;
374
+ export declare function parseIPXURL(url: string): IPXParsedURL;
334
375
  type HTTPStorageOptions = {
335
376
  /**
336
377
  * Custom options for fetch operations, such as headers or method overrides.
@@ -345,7 +386,13 @@ type HTTPStorageOptions = {
345
386
  */
346
387
  fetchOptions?: RequestInit;
347
388
  /**
348
- * Default maximum age (in seconds) for cache control. If not specified, defaults to the environment setting or 300 seconds.
389
+ * Fallback `cache-control` max-age (in seconds), used when the upstream response has no
390
+ * `max-age` directive or {@link HTTPStorageOptions.ignoreCacheControl} is set. If not
391
+ * specified, defaults to `IPX_HTTP_MAX_AGE` or 300 seconds.
392
+ *
393
+ * `0` is kept rather than replaced by the default: caches may still store the response but
394
+ * must revalidate it on every request. It does not override an upstream `max-age` unless
395
+ * `ignoreCacheControl` is set. Invalid values (negative, `NaN`, non-numeric) count as unset.
349
396
  * @optional
350
397
  */
351
398
  maxAge?: number;
@@ -379,6 +426,12 @@ type HTTPStorageOptions = {
379
426
  * (`169.254.169.254`, the cloud metadata service), RFC1918, CGNAT, unique-local or
380
427
  * otherwise non-public space.
381
428
  *
429
+ * Best effort: it does not protect against DNS rebinding. The hostname is validated, then
430
+ * resolved again when the socket is opened, so an attacker-controlled record with a short
431
+ * TTL can answer with a public address first and a private one second. When IPX must not
432
+ * reach internal services, enforce that at the network level (egress proxy, firewall or
433
+ * network policy) instead of relying on this option alone.
434
+ *
382
435
  * Disabled by default because many legitimate deployments fetch from in-cluster origins
383
436
  * (internal object storage, sidecars, `localhost` during development), which this blocks.
384
437
  * @optional
@@ -394,7 +447,7 @@ type HTTPStorageOptions = {
394
447
  * @returns {IPXStorage} An IPXStorage interface implementation for retrieving images over HTTP. See {@link IPXStorage}.
395
448
  * @throws {H3Error} If validation of the requested URL fails due to a missing hostname or denied host access. See {@link H3Error}.
396
449
  */
397
- declare function ipxHttpStorage(_options?: HTTPStorageOptions): IPXStorage;
450
+ export declare function ipxHttpStorage(_options?: HTTPStorageOptions): IPXStorage;
398
451
  type NodeFSSOptions = {
399
452
  /**
400
453
  * The directory or list of directories from which to serve files. If not specified, the current directory is used by default.
@@ -437,7 +490,7 @@ type NodeFSSOptions = {
437
490
  * @returns {IPXStorage} An implementation of the IPXStorage interface for accessing images stored on the local file system. See {@link IPXStorage}.
438
491
  * @throws {H3Error} If there is a problem accessing the file system module or resolving/reading files. See {@link H3Error}.
439
492
  */
440
- declare function ipxFSStorage(_options?: NodeFSSOptions): IPXStorage;
493
+ export declare function ipxFSStorage(_options?: NodeFSSOptions): IPXStorage;
441
494
  type UnstorageIPXStorageOptions = {
442
495
  /**
443
496
  * Optional prefix to be placed in front of each storage key, which can help to name or categorise stored items.
@@ -454,5 +507,5 @@ type UnstorageIPXStorageOptions = {
454
507
  * @returns {IPXStorage}. An IPXStorage compliant object that implements the necessary methods to interact with the provided unstorage driver or storage system. See {@link IPXStorage}.
455
508
  * @throws {H3Error} If there is a problem retrieving or converting the storage data, detailed error information is thrown. See {@link H3Error}.
456
509
  */
457
- declare function unstorageToIPXStorage(storage: Storage | Driver, _options?: UnstorageIPXStorageOptions | string): IPXStorage;
458
- export { HTTPError, type HTTPStorageOptions, type Handler, type HandlerContext, type IPX, type IPXHandlerOptions, type IPXModifiers, type IPXOptions, type IPXParsedURL, type IPXStorage, type IPXStorageMeta, type IPXStorageOptions, type IPXURLParser, type NodeFSSOptions, type UnstorageIPXStorageOptions, createIPX, createIPXFetchHandler, createIPXNodeHandler, ipxFSStorage, ipxHttpStorage, parseIPXURL, serveIPX, unstorageToIPXStorage };
510
+ export declare function unstorageToIPXStorage(storage: Storage | Driver, _options?: UnstorageIPXStorageOptions | string): IPXStorage;
511
+ export { HTTPError, type HTTPStorageOptions, type Handler, type HandlerContext, type IPX, type IPXAutoFormat, type IPXHandlerOptions, type IPXModifiers, type IPXOptions, type IPXParsedURL, type IPXStorage, type IPXStorageMeta, type IPXStorageOptions, type IPXURLParser, type NodeFSSOptions, type UnstorageIPXStorageOptions };
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "ipx",
3
- "version": "4.0.0-beta.1",
3
+ "version": "4.0.0",
4
4
  "description": "High performance, secure and easy-to-use image optimizer.",
5
- "repository": "unjs/ipx",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "unjs/ipx"
8
+ },
6
9
  "license": "MIT",
7
10
  "sideEffects": false,
8
11
  "type": "module",
@@ -17,42 +20,30 @@
17
20
  "files": [
18
21
  "dist"
19
22
  ],
20
- "scripts": {
21
- "build": "obuild",
22
- "gen:operations": "node ./scripts/gen-operations.ts",
23
- "ipx": "node ./src/cli.ts",
24
- "lint": "eslint . && prettier -c src test scripts automd.config.ts",
25
- "lint:fix": "automd && eslint . --fix && prettier -w src test scripts automd.config.ts",
26
- "prepack": "pnpm build",
27
- "release": "pnpm test && pnpm build && changelogen --release --prerelease --push && npm publish --tag latest",
28
- "start": "node bin/ipx.js",
29
- "test": "pnpm lint && pnpm test:types && vitest run --coverage",
30
- "test:types": "tsc --noEmit"
31
- },
32
23
  "dependencies": {
33
- "sharp": "^0.35.3",
34
- "srvx": "^0.12.4"
24
+ "sharp": "^0.35.5",
25
+ "srvx": "^1.0.5"
35
26
  },
36
27
  "devDependencies": {
37
- "@fastify/accept-negotiator": "^2.0.1",
28
+ "@fastify/accept-negotiator": "^2.1.1",
38
29
  "@types/etag": "^1.8.4",
39
30
  "@types/is-valid-path": "^0.1.2",
40
- "@vitest/coverage-v8": "^4.1.10",
31
+ "@vitest/coverage-v8": "^5.0.3",
41
32
  "automd": "^0.4.3",
42
33
  "changelogen": "^0.6.2",
43
- "eslint": "^10.8.0",
34
+ "eslint": "^10.12.0",
44
35
  "eslint-config-unjs": "^0.6.2",
45
36
  "etag": "^1.8.1",
46
- "h3": "2.0.1-rc.26",
47
- "image-meta": "^0.2.2",
48
- "obuild": "^0.4.38",
49
- "oxc-minify": ">=0.141.0",
50
- "prettier": "^3.9.6",
51
- "svgo": "^4.0.2",
52
- "typescript": "^6",
37
+ "h3": "^2.0.2",
38
+ "image-meta": "^0.3.0",
39
+ "obuild": "^0.4.44",
40
+ "oxc-minify": ">=0.153.0",
41
+ "prettier": "^3.9.10",
42
+ "svgo": "^4.1.0",
43
+ "typescript": "^6.0.3",
53
44
  "ufo": "^1.6.4",
54
45
  "unstorage": "^1.17.5",
55
- "vitest": "^4.1.10"
46
+ "vitest": "^5.0.3"
56
47
  },
57
48
  "peerDependencies": {
58
49
  "unstorage": "*"
@@ -65,5 +56,15 @@
65
56
  "engines": {
66
57
  "node": "^20.16.0 || >=22.3.0"
67
58
  },
68
- "packageManager": "pnpm@11.17.0"
69
- }
59
+ "scripts": {
60
+ "build": "obuild",
61
+ "gen:operations": "node ./scripts/gen-operations.ts",
62
+ "ipx": "node ./src/cli.ts",
63
+ "lint": "eslint . && prettier -c src test scripts automd.config.ts",
64
+ "lint:fix": "automd && eslint . --fix && prettier -w src test scripts automd.config.ts",
65
+ "release": "pnpm test && pnpm build && changelogen --release --push && pnpm publish --tag latest",
66
+ "start": "node bin/ipx.js",
67
+ "test": "pnpm lint && pnpm test:types && vitest run --coverage",
68
+ "test:types": "tsc --noEmit"
69
+ }
70
+ }