ipx 0.7.0 → 0.9.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.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  High performance, secure and easy to use image proxy based on [sharp](https://github.com/lovell/sharp) and [libvips](https://github.com/libvips/libvips).
8
8
 
9
- <h2 align="center">Usage</h2>
9
+ ## Usage
10
10
 
11
11
  ### Quick Start
12
12
 
@@ -46,31 +46,33 @@ Keep original format (`png`) and set width to `200`:
46
46
 
47
47
  `http://localhost:3000/w_200/static/buffalo.png`
48
48
 
49
- Resize to `200px` using `embed` method and change format to `webp`:
49
+ Resize to `200x200px` using `embed` method and change format to `webp`:
50
50
 
51
- `http://localhost:3000/embed,f_webp,s_200/static/buffalo.png`
51
+ `http://localhost:3000/embed,f_webp,s_200x200/static/buffalo.png`
52
52
 
53
53
  ### Modifiers
54
54
 
55
- | Property | Docs | Example | Comments |
56
- | --------- | :-------------------------------------------------------------- | :----------------------------------------------- | :-------------------------------------------------------------------- |
57
- | width | \_ | `http://localhost:3000/width_200/buffalo.png` |
58
- | height | \_ | `http://localhost:3000/height_200/buffalo.png` |
59
- | trim | [Docs](https://sharp.pixelplumbing.com/api-resize#trim) | `http://localhost:3000/trim_100/buffalo.png` |
60
- | format | [Docs](https://sharp.pixelplumbing.com/api-output#toformat) | `http://localhost:3000/format_webp/buffalo.png` | Supported format: `jpg`, `jpeg`, `png`, `webp`, `avif`, `gif`, `heif` |
61
- | quality | \_ | `http://localhost:3000/quality_50/buffalo.png` | Accepted values: 0 to 100 |
62
- | rotate | [Docs](https://sharp.pixelplumbing.com/api-operation#rotate) | `http://localhost:3000/rotate_45/buffalo.png` |
63
- | flip | [Docs](https://sharp.pixelplumbing.com/api-operation#flip) | `http://localhost:3000/flip/buffalo.png` |
64
- | flop | [Docs](https://sharp.pixelplumbing.com/api-operation#flop) | `http://localhost:3000/flop/buffalo.png` |
65
- | sharpen | [Docs](https://sharp.pixelplumbing.com/api-operation#sharpen) | `http://localhost:3000/sharpen_30/buffalo.png` |
66
- | median | [Docs](https://sharp.pixelplumbing.com/api-operation#median) | `http://localhost:3000/median_10/buffalo.png` |
67
- | gamma | [Docs](https://sharp.pixelplumbing.com/api-operation#gamma) | `http://localhost:3000/gamma_3/buffalo.png` |
68
- | negate | [Docs](https://sharp.pixelplumbing.com/api-operation#negate) | `http://localhost:3000/negate/buffalo.png` |
69
- | normalize | [Docs](https://sharp.pixelplumbing.com/api-operation#normalize) | `http://localhost:3000/normalize/buffalo.png` |
70
- | threshold | [Docs](https://sharp.pixelplumbing.com/api-operation#threshold) | `http://localhost:3000/threshold_10/buffalo.png` |
71
- | tint | [Docs](https://sharp.pixelplumbing.com/api-colour#tint) | `http://localhost:3000/tint_1098123/buffalo.png` |
72
- | grayscale | [Docs](https://sharp.pixelplumbing.com/api-colour#grayscale) | `http://localhost:3000/grayscale/buffalo.png` |
73
- | animated | - | `http://localhost:3000/animated/buffalo.gif` | Experimental |
55
+ | Property | Docs | Example | Comments |
56
+ | ----------- | :-------------------------------------------------------------- | :------------------------------------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
57
+ | width / w | \_ | `http://localhost:3000/width_200/buffalo.png` |
58
+ | height / h | \_ | `http://localhost:3000/height_200/buffalo.png` |
59
+ | resize / s | \_ | `http://localhost:3000/s_200x200/buffalo.png` |
60
+ | trim | [Docs](https://sharp.pixelplumbing.com/api-resize#trim) | `http://localhost:3000/trim_100/buffalo.png` |
61
+ | format | [Docs](https://sharp.pixelplumbing.com/api-output#toformat) | `http://localhost:3000/format_webp/buffalo.png` | Supported format: `jpg`, `jpeg`, `png`, `webp`, `avif`, `gif`, `heif` |
62
+ | quality / q | \_ | `http://localhost:3000/quality_50/buffalo.png` | Accepted values: 0 to 100 |
63
+ | rotate | [Docs](https://sharp.pixelplumbing.com/api-operation#rotate) | `http://localhost:3000/rotate_45/buffalo.png` |
64
+ | enlarge | \_ | `http://localhost:3000/enlarge,s_2000x2000/buffalo.png` | Allow the image to be upscaled. By default the returned image will never be larger than the source in any dimension, while preserving the requested aspect ratio. |
65
+ | flip | [Docs](https://sharp.pixelplumbing.com/api-operation#flip) | `http://localhost:3000/flip/buffalo.png` |
66
+ | flop | [Docs](https://sharp.pixelplumbing.com/api-operation#flop) | `http://localhost:3000/flop/buffalo.png` |
67
+ | sharpen | [Docs](https://sharp.pixelplumbing.com/api-operation#sharpen) | `http://localhost:3000/sharpen_30/buffalo.png` |
68
+ | median | [Docs](https://sharp.pixelplumbing.com/api-operation#median) | `http://localhost:3000/median_10/buffalo.png` |
69
+ | gamma | [Docs](https://sharp.pixelplumbing.com/api-operation#gamma) | `http://localhost:3000/gamma_3/buffalo.png` |
70
+ | negate | [Docs](https://sharp.pixelplumbing.com/api-operation#negate) | `http://localhost:3000/negate/buffalo.png` |
71
+ | normalize | [Docs](https://sharp.pixelplumbing.com/api-operation#normalize) | `http://localhost:3000/normalize/buffalo.png` |
72
+ | threshold | [Docs](https://sharp.pixelplumbing.com/api-operation#threshold) | `http://localhost:3000/threshold_10/buffalo.png` |
73
+ | tint | [Docs](https://sharp.pixelplumbing.com/api-colour#tint) | `http://localhost:3000/tint_1098123/buffalo.png` |
74
+ | grayscale | [Docs](https://sharp.pixelplumbing.com/api-colour#grayscale) | `http://localhost:3000/grayscale/buffalo.png` |
75
+ | animated | - | `http://localhost:3000/animated/buffalo.gif` | Experimental |
74
76
 
75
77
  ### Config
76
78
 
@@ -83,6 +85,6 @@ Config can be customized using `IPX_*` environment variables.
83
85
  - `IPX_DOMAINS`
84
86
  - Default: `[]`
85
87
 
86
- <h2 align="center">License</h2>
88
+ ## License
87
89
 
88
90
  MIT
package/dist/index.cjs CHANGED
@@ -2,38 +2,35 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
- const Sharp = require('sharp');
6
5
  const defu = require('defu');
7
6
  const imageMeta = require('image-meta');
8
7
  const ufo = require('ufo');
9
- const path = require('path');
8
+ const fs = require('fs');
9
+ const pathe = require('pathe');
10
10
  const isValidPath = require('is-valid-path');
11
- const fsExtra = require('fs-extra');
12
11
  const destr = require('destr');
13
12
  const http = require('http');
14
13
  const https = require('https');
15
- const fetch = require('node-fetch');
14
+ const ohmyfetch = require('ohmyfetch');
16
15
  const getEtag = require('etag');
17
16
  const xss = require('xss');
18
17
 
19
- function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
18
+ function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e["default"] : e; }
20
19
 
21
- const Sharp__default = /*#__PURE__*/_interopDefaultLegacy(Sharp);
22
20
  const defu__default = /*#__PURE__*/_interopDefaultLegacy(defu);
23
- const imageMeta__default = /*#__PURE__*/_interopDefaultLegacy(imageMeta);
24
21
  const isValidPath__default = /*#__PURE__*/_interopDefaultLegacy(isValidPath);
25
22
  const destr__default = /*#__PURE__*/_interopDefaultLegacy(destr);
26
23
  const http__default = /*#__PURE__*/_interopDefaultLegacy(http);
27
24
  const https__default = /*#__PURE__*/_interopDefaultLegacy(https);
28
- const fetch__default = /*#__PURE__*/_interopDefaultLegacy(fetch);
29
25
  const getEtag__default = /*#__PURE__*/_interopDefaultLegacy(getEtag);
30
26
  const xss__default = /*#__PURE__*/_interopDefaultLegacy(xss);
31
27
 
32
- const Handlers = /*#__PURE__*/Object.freeze({
28
+ const Handlers = {
33
29
  __proto__: null,
34
30
  get quality () { return quality; },
35
31
  get fit () { return fit; },
36
32
  get background () { return background; },
33
+ get enlarge () { return enlarge; },
37
34
  get width () { return width; },
38
35
  get height () { return height; },
39
36
  get resize () { return resize; },
@@ -60,11 +57,10 @@ const Handlers = /*#__PURE__*/Object.freeze({
60
57
  get w () { return w; },
61
58
  get h () { return h; },
62
59
  get s () { return s; }
63
- });
60
+ };
64
61
 
65
62
  function getEnv(name, defaultValue) {
66
- var _a;
67
- return (_a = destr__default['default'](process.env[name])) != null ? _a : defaultValue;
63
+ return destr__default(process.env[name]) ?? defaultValue;
68
64
  }
69
65
  function cachedPromise(fn) {
70
66
  let p;
@@ -86,15 +82,15 @@ function createError(message, statusCode) {
86
82
  }
87
83
 
88
84
  const createFilesystemSource = (options) => {
89
- const rootDir = path.resolve(options.dir);
85
+ const rootDir = pathe.resolve(options.dir);
90
86
  return async (id) => {
91
- const fsPath = path.resolve(path.join(rootDir, id));
92
- if (!isValidPath__default['default'](id) || id.includes("..") || !fsPath.startsWith(rootDir)) {
87
+ const fsPath = pathe.resolve(pathe.join(rootDir, id));
88
+ if (!isValidPath__default(id) || id.includes("..") || !fsPath.startsWith(rootDir)) {
93
89
  throw createError("Forbidden path:" + id, 403);
94
90
  }
95
91
  let stats;
96
92
  try {
97
- stats = await fsExtra.stat(fsPath);
93
+ stats = await fs.promises.stat(fsPath);
98
94
  } catch (err) {
99
95
  if (err.code === "ENOENT") {
100
96
  throw createError("File not found: " + fsPath, 404);
@@ -108,14 +104,14 @@ const createFilesystemSource = (options) => {
108
104
  return {
109
105
  mtime: stats.mtime,
110
106
  maxAge: options.maxAge || 300,
111
- getData: cachedPromise(() => fsExtra.readFile(fsPath))
107
+ getData: cachedPromise(() => fs.promises.readFile(fsPath))
112
108
  };
113
109
  };
114
110
  };
115
111
 
116
112
  const createHTTPSource = (options) => {
117
- const httpsAgent = new https__default['default'].Agent({ keepAlive: true });
118
- const httpAgent = new http__default['default'].Agent({ keepAlive: true });
113
+ const httpsAgent = new https__default.Agent({ keepAlive: true });
114
+ const httpAgent = new http__default.Agent({ keepAlive: true });
119
115
  let domains = options.domains || [];
120
116
  if (typeof domains === "string") {
121
117
  domains = domains.split(",").map((s) => s.trim());
@@ -126,10 +122,10 @@ const createHTTPSource = (options) => {
126
122
  if (!parsedUrl.host) {
127
123
  throw createError("Hostname is missing: " + id, 403);
128
124
  }
129
- if (!(reqOptions == null ? void 0 : reqOptions.bypassDomain) && !hosts.find((host) => parsedUrl.host === host)) {
125
+ if (!reqOptions?.bypassDomain && !hosts.find((host) => parsedUrl.host === host)) {
130
126
  throw createError("Forbidden host: " + parsedUrl.host, 403);
131
127
  }
132
- const response = await fetch__default['default'](id, {
128
+ const response = await ohmyfetch.fetch(id, {
133
129
  agent: id.startsWith("https") ? httpsAgent : httpAgent
134
130
  });
135
131
  if (!response.ok) {
@@ -157,7 +153,7 @@ const createHTTPSource = (options) => {
157
153
  };
158
154
 
159
155
  function VArg(arg) {
160
- return destr__default['default'](arg);
156
+ return destr__default(arg);
161
157
  }
162
158
  function parseArgs(args, mappers) {
163
159
  const vargs = args.split("_");
@@ -170,6 +166,19 @@ function applyHandler(ctx, pipe, handler, argsStr) {
170
166
  const args = handler.args ? parseArgs(argsStr, handler.args) : [];
171
167
  return handler.apply(ctx, pipe, ...args);
172
168
  }
169
+ function clampDimensionsPreservingAspectRatio(sourceDimensions, desiredDimensions) {
170
+ const desiredAspectRatio = desiredDimensions.width / desiredDimensions.height;
171
+ let { width, height } = desiredDimensions;
172
+ if (width > sourceDimensions.width) {
173
+ width = sourceDimensions.width;
174
+ height = Math.round(sourceDimensions.width / desiredAspectRatio);
175
+ }
176
+ if (height > sourceDimensions.height) {
177
+ height = sourceDimensions.height;
178
+ width = Math.round(sourceDimensions.height * desiredAspectRatio);
179
+ }
180
+ return { width, height };
181
+ }
173
182
 
174
183
  const quality = {
175
184
  args: [VArg],
@@ -191,27 +200,40 @@ const background = {
191
200
  args: [VArg],
192
201
  order: -1,
193
202
  apply: (context, _pipe, background2) => {
203
+ background2 = String(background2);
194
204
  if (!background2.startsWith("#") && (HEX_RE.test(background2) || SHORTHEX_RE.test(background2))) {
195
205
  background2 = "#" + background2;
196
206
  }
197
207
  context.background = background2;
198
208
  }
199
209
  };
210
+ const enlarge = {
211
+ args: [],
212
+ apply: (context) => {
213
+ context.enlarge = true;
214
+ }
215
+ };
200
216
  const width = {
201
217
  args: [VArg],
202
- apply: (_context, pipe, width2) => {
203
- return pipe.resize(width2, null);
218
+ apply: (context, pipe, width2) => {
219
+ return pipe.resize(width2, null, { withoutEnlargement: !context.enlarge });
204
220
  }
205
221
  };
206
222
  const height = {
207
223
  args: [VArg],
208
- apply: (_context, pipe, height2) => {
209
- return pipe.resize(null, height2);
224
+ apply: (context, pipe, height2) => {
225
+ return pipe.resize(null, height2, { withoutEnlargement: !context.enlarge });
210
226
  }
211
227
  };
212
228
  const resize = {
213
229
  args: [VArg, VArg, VArg],
214
- apply: (context, pipe, width2, height2) => {
230
+ apply: (context, pipe, size) => {
231
+ let [width2, height2] = String(size).split("x").map((v) => Number(v));
232
+ if (!context.enlarge) {
233
+ const clamped = clampDimensionsPreservingAspectRatio(context.meta, { width: width2, height: height2 });
234
+ width2 = clamped.width;
235
+ height2 = clamped.height;
236
+ }
215
237
  return pipe.resize(width2, height2, {
216
238
  fit: context.fit,
217
239
  background: context.background
@@ -250,8 +272,10 @@ const extract = {
250
272
  };
251
273
  const rotate = {
252
274
  args: [VArg],
253
- apply: (_context, pipe, angel) => {
254
- return pipe.rotate(angel);
275
+ apply: (context, pipe, angel) => {
276
+ return pipe.rotate(angel, {
277
+ background: context.background
278
+ });
255
279
  }
256
280
  };
257
281
  const flip = {
@@ -353,7 +377,7 @@ function createIPX(userOptions) {
353
377
  alias: getEnv("IPX_ALIAS", {}),
354
378
  sharp: {}
355
379
  };
356
- const options = defu__default['default'](userOptions, defaults);
380
+ const options = defu__default(userOptions, defaults);
357
381
  options.alias = Object.fromEntries(Object.entries(options.alias).map((e) => [ufo.withLeadingSlash(e[0]), e[1]]));
358
382
  const ctx = {
359
383
  sources: {}
@@ -388,7 +412,7 @@ function createIPX(userOptions) {
388
412
  const getData = cachedPromise(async () => {
389
413
  const src = await getSrc();
390
414
  const data = await src.getData();
391
- const meta = imageMeta__default['default'](data);
415
+ const meta = imageMeta.imageMeta(data);
392
416
  const mFormat = modifiers.f || modifiers.format;
393
417
  let format = mFormat || meta.type;
394
418
  if (format === "jpg") {
@@ -405,14 +429,15 @@ function createIPX(userOptions) {
405
429
  if (animated) {
406
430
  format = "webp";
407
431
  }
408
- let sharp = Sharp__default['default'](data, { animated });
432
+ const Sharp = await import('sharp').then((r) => r.default || r);
433
+ let sharp = Sharp(data, { animated });
409
434
  Object.assign(sharp.options, options.sharp);
410
435
  const handlers = Object.entries(modifiers).map(([name, args]) => ({ handler: getHandler(name), name, args })).filter((h) => h.handler).sort((a, b) => {
411
436
  const aKey = (a.handler.order || a.name || "").toString();
412
437
  const bKey = (b.handler.order || b.name || "").toString();
413
438
  return aKey.localeCompare(bKey);
414
439
  });
415
- const handlerCtx = {};
440
+ const handlerCtx = { meta };
416
441
  for (const h of handlers) {
417
442
  sharp = applyHandler(handlerCtx, sharp, h.handler, h.args) || sharp;
418
443
  }
@@ -473,7 +498,7 @@ async function _handleRequest(req, ipx) {
473
498
  res.headers["Cache-Control"] = `max-age=${+src.maxAge}, public, s-maxage=${+src.maxAge}`;
474
499
  }
475
500
  const { data, format } = await img.data();
476
- const etag = getEtag__default['default'](data);
501
+ const etag = getEtag__default(data);
477
502
  res.headers.ETag = etag;
478
503
  if (etag && req.headers["if-none-match"] === etag) {
479
504
  res.statusCode = 304;
@@ -488,7 +513,7 @@ async function _handleRequest(req, ipx) {
488
513
  function handleRequest(req, ipx) {
489
514
  return _handleRequest(req, ipx).catch((err) => {
490
515
  const statusCode = parseInt(err.statusCode) || 500;
491
- const statusMessage = err.statusMessage ? xss__default['default'](err.statusMessage) : `IPX Error (${statusCode})`;
516
+ const statusMessage = err.statusMessage ? xss__default(err.statusMessage) : `IPX Error (${statusCode})`;
492
517
  if (process.env.NODE_ENV !== "production" && statusCode === 500) {
493
518
  console.error(err);
494
519
  }
package/dist/index.mjs CHANGED
@@ -1,22 +1,22 @@
1
- import Sharp from 'sharp';
2
1
  import defu from 'defu';
3
- import imageMeta from 'image-meta';
2
+ import { imageMeta } from 'image-meta';
4
3
  import { parseURL, withLeadingSlash, hasProtocol, joinURL, decode } from 'ufo';
5
- import { resolve, join } from 'path';
4
+ import { promises } from 'fs';
5
+ import { resolve, join } from 'pathe';
6
6
  import isValidPath from 'is-valid-path';
7
- import { stat, readFile } from 'fs-extra';
8
7
  import destr from 'destr';
9
8
  import http from 'http';
10
9
  import https from 'https';
11
- import fetch from 'node-fetch';
10
+ import { fetch } from 'ohmyfetch';
12
11
  import getEtag from 'etag';
13
12
  import xss from 'xss';
14
13
 
15
- var Handlers = /*#__PURE__*/Object.freeze({
14
+ const Handlers = {
16
15
  __proto__: null,
17
16
  get quality () { return quality; },
18
17
  get fit () { return fit; },
19
18
  get background () { return background; },
19
+ get enlarge () { return enlarge; },
20
20
  get width () { return width; },
21
21
  get height () { return height; },
22
22
  get resize () { return resize; },
@@ -43,11 +43,10 @@ var Handlers = /*#__PURE__*/Object.freeze({
43
43
  get w () { return w; },
44
44
  get h () { return h; },
45
45
  get s () { return s; }
46
- });
46
+ };
47
47
 
48
48
  function getEnv(name, defaultValue) {
49
- var _a;
50
- return (_a = destr(process.env[name])) != null ? _a : defaultValue;
49
+ return destr(process.env[name]) ?? defaultValue;
51
50
  }
52
51
  function cachedPromise(fn) {
53
52
  let p;
@@ -77,7 +76,7 @@ const createFilesystemSource = (options) => {
77
76
  }
78
77
  let stats;
79
78
  try {
80
- stats = await stat(fsPath);
79
+ stats = await promises.stat(fsPath);
81
80
  } catch (err) {
82
81
  if (err.code === "ENOENT") {
83
82
  throw createError("File not found: " + fsPath, 404);
@@ -91,7 +90,7 @@ const createFilesystemSource = (options) => {
91
90
  return {
92
91
  mtime: stats.mtime,
93
92
  maxAge: options.maxAge || 300,
94
- getData: cachedPromise(() => readFile(fsPath))
93
+ getData: cachedPromise(() => promises.readFile(fsPath))
95
94
  };
96
95
  };
97
96
  };
@@ -109,7 +108,7 @@ const createHTTPSource = (options) => {
109
108
  if (!parsedUrl.host) {
110
109
  throw createError("Hostname is missing: " + id, 403);
111
110
  }
112
- if (!(reqOptions == null ? void 0 : reqOptions.bypassDomain) && !hosts.find((host) => parsedUrl.host === host)) {
111
+ if (!reqOptions?.bypassDomain && !hosts.find((host) => parsedUrl.host === host)) {
113
112
  throw createError("Forbidden host: " + parsedUrl.host, 403);
114
113
  }
115
114
  const response = await fetch(id, {
@@ -153,6 +152,19 @@ function applyHandler(ctx, pipe, handler, argsStr) {
153
152
  const args = handler.args ? parseArgs(argsStr, handler.args) : [];
154
153
  return handler.apply(ctx, pipe, ...args);
155
154
  }
155
+ function clampDimensionsPreservingAspectRatio(sourceDimensions, desiredDimensions) {
156
+ const desiredAspectRatio = desiredDimensions.width / desiredDimensions.height;
157
+ let { width, height } = desiredDimensions;
158
+ if (width > sourceDimensions.width) {
159
+ width = sourceDimensions.width;
160
+ height = Math.round(sourceDimensions.width / desiredAspectRatio);
161
+ }
162
+ if (height > sourceDimensions.height) {
163
+ height = sourceDimensions.height;
164
+ width = Math.round(sourceDimensions.height * desiredAspectRatio);
165
+ }
166
+ return { width, height };
167
+ }
156
168
 
157
169
  const quality = {
158
170
  args: [VArg],
@@ -174,27 +186,40 @@ const background = {
174
186
  args: [VArg],
175
187
  order: -1,
176
188
  apply: (context, _pipe, background2) => {
189
+ background2 = String(background2);
177
190
  if (!background2.startsWith("#") && (HEX_RE.test(background2) || SHORTHEX_RE.test(background2))) {
178
191
  background2 = "#" + background2;
179
192
  }
180
193
  context.background = background2;
181
194
  }
182
195
  };
196
+ const enlarge = {
197
+ args: [],
198
+ apply: (context) => {
199
+ context.enlarge = true;
200
+ }
201
+ };
183
202
  const width = {
184
203
  args: [VArg],
185
- apply: (_context, pipe, width2) => {
186
- return pipe.resize(width2, null);
204
+ apply: (context, pipe, width2) => {
205
+ return pipe.resize(width2, null, { withoutEnlargement: !context.enlarge });
187
206
  }
188
207
  };
189
208
  const height = {
190
209
  args: [VArg],
191
- apply: (_context, pipe, height2) => {
192
- return pipe.resize(null, height2);
210
+ apply: (context, pipe, height2) => {
211
+ return pipe.resize(null, height2, { withoutEnlargement: !context.enlarge });
193
212
  }
194
213
  };
195
214
  const resize = {
196
215
  args: [VArg, VArg, VArg],
197
- apply: (context, pipe, width2, height2) => {
216
+ apply: (context, pipe, size) => {
217
+ let [width2, height2] = String(size).split("x").map((v) => Number(v));
218
+ if (!context.enlarge) {
219
+ const clamped = clampDimensionsPreservingAspectRatio(context.meta, { width: width2, height: height2 });
220
+ width2 = clamped.width;
221
+ height2 = clamped.height;
222
+ }
198
223
  return pipe.resize(width2, height2, {
199
224
  fit: context.fit,
200
225
  background: context.background
@@ -233,8 +258,10 @@ const extract = {
233
258
  };
234
259
  const rotate = {
235
260
  args: [VArg],
236
- apply: (_context, pipe, angel) => {
237
- return pipe.rotate(angel);
261
+ apply: (context, pipe, angel) => {
262
+ return pipe.rotate(angel, {
263
+ background: context.background
264
+ });
238
265
  }
239
266
  };
240
267
  const flip = {
@@ -388,6 +415,7 @@ function createIPX(userOptions) {
388
415
  if (animated) {
389
416
  format = "webp";
390
417
  }
418
+ const Sharp = await import('sharp').then((r) => r.default || r);
391
419
  let sharp = Sharp(data, { animated });
392
420
  Object.assign(sharp.options, options.sharp);
393
421
  const handlers = Object.entries(modifiers).map(([name, args]) => ({ handler: getHandler(name), name, args })).filter((h) => h.handler).sort((a, b) => {
@@ -395,7 +423,7 @@ function createIPX(userOptions) {
395
423
  const bKey = (b.handler.order || b.name || "").toString();
396
424
  return aKey.localeCompare(bKey);
397
425
  });
398
- const handlerCtx = {};
426
+ const handlerCtx = { meta };
399
427
  for (const h of handlers) {
400
428
  sharp = applyHandler(handlerCtx, sharp, h.handler, h.args) || sharp;
401
429
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ipx",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "repository": "unjs/ipx",
5
5
  "license": "MIT",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "dist"
18
18
  ],
19
19
  "scripts": {
20
- "build": "siroc build",
20
+ "build": "unbuild",
21
21
  "dev": "nodemon",
22
22
  "lint": "eslint --ext .ts .",
23
- "prepublishOnly": "yarn build",
23
+ "prepack": "yarn build",
24
24
  "release": "yarn test && standard-version && git push --follow-tags && npm publish",
25
25
  "start": "node bin/ipx.js",
26
26
  "test": "yarn lint && jest"
@@ -30,19 +30,18 @@
30
30
  "defu": "^5.0.0",
31
31
  "destr": "^1.1.0",
32
32
  "etag": "^1.8.1",
33
- "fs-extra": "^10.0.0",
34
- "image-meta": "^0.0.1",
33
+ "image-meta": "^0.1.1",
35
34
  "is-valid-path": "^0.1.1",
36
- "listhen": "^0.2.4",
37
- "node-fetch": "^2.6.1",
38
- "sharp": "^0.28.3",
39
- "ufo": "^0.7.7",
40
- "xss": "^1.0.9"
35
+ "listhen": "^0.2.5",
36
+ "ohmyfetch": "^0.4.2",
37
+ "pathe": "^0.2.0",
38
+ "sharp": "^0.29.0",
39
+ "ufo": "^0.7.9",
40
+ "xss": "^1.0.10"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@nuxtjs/eslint-config-typescript": "latest",
44
44
  "@types/etag": "latest",
45
- "@types/fs-extra": "latest",
46
45
  "@types/is-valid-path": "latest",
47
46
  "@types/jest": "latest",
48
47
  "@types/node-fetch": "latest",
@@ -51,9 +50,9 @@
51
50
  "jest": "latest",
52
51
  "jiti": "latest",
53
52
  "nodemon": "latest",
54
- "siroc": "latest",
55
53
  "standard-version": "latest",
56
54
  "ts-jest": "latest",
57
- "typescript": "latest"
55
+ "typescript": "latest",
56
+ "unbuild": "latest"
58
57
  }
59
58
  }
package/dist/cli.js DELETED
@@ -1,529 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- 'use strict';
4
-
5
- const consola = require('consola');
6
- const listhen = require('listhen');
7
- const Sharp = require('sharp');
8
- const defu = require('defu');
9
- const imageMeta = require('image-meta');
10
- const ufo = require('ufo');
11
- const path = require('path');
12
- const isValidPath = require('is-valid-path');
13
- const fsExtra = require('fs-extra');
14
- const destr = require('destr');
15
- const http = require('http');
16
- const https = require('https');
17
- const fetch = require('node-fetch');
18
- const getEtag = require('etag');
19
- const xss = require('xss');
20
-
21
- function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
22
-
23
- const consola__default = /*#__PURE__*/_interopDefaultLegacy(consola);
24
- const Sharp__default = /*#__PURE__*/_interopDefaultLegacy(Sharp);
25
- const defu__default = /*#__PURE__*/_interopDefaultLegacy(defu);
26
- const imageMeta__default = /*#__PURE__*/_interopDefaultLegacy(imageMeta);
27
- const isValidPath__default = /*#__PURE__*/_interopDefaultLegacy(isValidPath);
28
- const destr__default = /*#__PURE__*/_interopDefaultLegacy(destr);
29
- const http__default = /*#__PURE__*/_interopDefaultLegacy(http);
30
- const https__default = /*#__PURE__*/_interopDefaultLegacy(https);
31
- const fetch__default = /*#__PURE__*/_interopDefaultLegacy(fetch);
32
- const getEtag__default = /*#__PURE__*/_interopDefaultLegacy(getEtag);
33
- const xss__default = /*#__PURE__*/_interopDefaultLegacy(xss);
34
-
35
- const Handlers = /*#__PURE__*/Object.freeze({
36
- __proto__: null,
37
- get quality () { return quality; },
38
- get fit () { return fit; },
39
- get background () { return background; },
40
- get width () { return width; },
41
- get height () { return height; },
42
- get resize () { return resize; },
43
- get trim () { return trim; },
44
- get extend () { return extend; },
45
- get extract () { return extract; },
46
- get rotate () { return rotate; },
47
- get flip () { return flip; },
48
- get flop () { return flop; },
49
- get sharpen () { return sharpen; },
50
- get median () { return median; },
51
- get blur () { return blur; },
52
- get flatten () { return flatten; },
53
- get gamma () { return gamma; },
54
- get negate () { return negate; },
55
- get normalize () { return normalize; },
56
- get threshold () { return threshold; },
57
- get modulate () { return modulate; },
58
- get tint () { return tint; },
59
- get grayscale () { return grayscale; },
60
- get crop () { return crop; },
61
- get q () { return q; },
62
- get b () { return b; },
63
- get w () { return w; },
64
- get h () { return h; },
65
- get s () { return s; }
66
- });
67
-
68
- function getEnv(name, defaultValue) {
69
- var _a;
70
- return (_a = destr__default['default'](process.env[name])) != null ? _a : defaultValue;
71
- }
72
- function cachedPromise(fn) {
73
- let p;
74
- return (...args) => {
75
- if (p) {
76
- return p;
77
- }
78
- p = Promise.resolve(fn(...args));
79
- return p;
80
- };
81
- }
82
- class IPXError extends Error {
83
- }
84
- function createError(message, statusCode) {
85
- const err = new IPXError(message);
86
- err.statusMessage = "IPX: " + message;
87
- err.statusCode = statusCode;
88
- return err;
89
- }
90
-
91
- const createFilesystemSource = (options) => {
92
- const rootDir = path.resolve(options.dir);
93
- return async (id) => {
94
- const fsPath = path.resolve(path.join(rootDir, id));
95
- if (!isValidPath__default['default'](id) || id.includes("..") || !fsPath.startsWith(rootDir)) {
96
- throw createError("Forbidden path:" + id, 403);
97
- }
98
- let stats;
99
- try {
100
- stats = await fsExtra.stat(fsPath);
101
- } catch (err) {
102
- if (err.code === "ENOENT") {
103
- throw createError("File not found: " + fsPath, 404);
104
- } else {
105
- throw createError("File access error for " + fsPath + ":" + err.code, 403);
106
- }
107
- }
108
- if (!stats.isFile()) {
109
- throw createError("Path should be a file: " + fsPath, 400);
110
- }
111
- return {
112
- mtime: stats.mtime,
113
- maxAge: options.maxAge || 300,
114
- getData: cachedPromise(() => fsExtra.readFile(fsPath))
115
- };
116
- };
117
- };
118
-
119
- const createHTTPSource = (options) => {
120
- const httpsAgent = new https__default['default'].Agent({ keepAlive: true });
121
- const httpAgent = new http__default['default'].Agent({ keepAlive: true });
122
- let domains = options.domains || [];
123
- if (typeof domains === "string") {
124
- domains = domains.split(",").map((s) => s.trim());
125
- }
126
- const hosts = domains.map((domain) => ufo.parseURL(domain, "https://").host);
127
- return async (id, reqOptions) => {
128
- const parsedUrl = ufo.parseURL(id, "https://");
129
- if (!parsedUrl.host) {
130
- throw createError("Hostname is missing: " + id, 403);
131
- }
132
- if (!(reqOptions == null ? void 0 : reqOptions.bypassDomain) && !hosts.find((host) => parsedUrl.host === host)) {
133
- throw createError("Forbidden host: " + parsedUrl.host, 403);
134
- }
135
- const response = await fetch__default['default'](id, {
136
- agent: id.startsWith("https") ? httpsAgent : httpAgent
137
- });
138
- if (!response.ok) {
139
- throw createError(response.statusText || "fetch error", response.status || 500);
140
- }
141
- let maxAge = options.maxAge || 300;
142
- const _cacheControl = response.headers.get("cache-control");
143
- if (_cacheControl) {
144
- const m = _cacheControl.match(/max-age=(\d+)/);
145
- if (m && m[1]) {
146
- maxAge = parseInt(m[1]);
147
- }
148
- }
149
- let mtime;
150
- const _lastModified = response.headers.get("last-modified");
151
- if (_lastModified) {
152
- mtime = new Date(_lastModified);
153
- }
154
- return {
155
- mtime,
156
- maxAge,
157
- getData: cachedPromise(() => response.buffer())
158
- };
159
- };
160
- };
161
-
162
- function VArg(arg) {
163
- return destr__default['default'](arg);
164
- }
165
- function parseArgs(args, mappers) {
166
- const vargs = args.split("_");
167
- return mappers.map((v, i) => v(vargs[i]));
168
- }
169
- function getHandler(key) {
170
- return Handlers[key];
171
- }
172
- function applyHandler(ctx, pipe, handler, argsStr) {
173
- const args = handler.args ? parseArgs(argsStr, handler.args) : [];
174
- return handler.apply(ctx, pipe, ...args);
175
- }
176
-
177
- const quality = {
178
- args: [VArg],
179
- order: -1,
180
- apply: (context, _pipe, quality2) => {
181
- context.quality = quality2;
182
- }
183
- };
184
- const fit = {
185
- args: [VArg],
186
- order: -1,
187
- apply: (context, _pipe, fit2) => {
188
- context.fit = fit2;
189
- }
190
- };
191
- const HEX_RE = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
192
- const SHORTHEX_RE = /^([a-f\d])([a-f\d])([a-f\d])$/i;
193
- const background = {
194
- args: [VArg],
195
- order: -1,
196
- apply: (context, _pipe, background2) => {
197
- if (!background2.startsWith("#") && (HEX_RE.test(background2) || SHORTHEX_RE.test(background2))) {
198
- background2 = "#" + background2;
199
- }
200
- context.background = background2;
201
- }
202
- };
203
- const width = {
204
- args: [VArg],
205
- apply: (_context, pipe, width2) => {
206
- return pipe.resize(width2, null);
207
- }
208
- };
209
- const height = {
210
- args: [VArg],
211
- apply: (_context, pipe, height2) => {
212
- return pipe.resize(null, height2);
213
- }
214
- };
215
- const resize = {
216
- args: [VArg, VArg, VArg],
217
- apply: (context, pipe, width2, height2) => {
218
- return pipe.resize(width2, height2, {
219
- fit: context.fit,
220
- background: context.background
221
- });
222
- }
223
- };
224
- const trim = {
225
- args: [VArg],
226
- apply: (_context, pipe, threshold2) => {
227
- return pipe.trim(threshold2);
228
- }
229
- };
230
- const extend = {
231
- args: [VArg, VArg, VArg, VArg],
232
- apply: (context, pipe, top, right, bottom, left) => {
233
- return pipe.extend({
234
- top,
235
- left,
236
- bottom,
237
- right,
238
- background: context.background
239
- });
240
- }
241
- };
242
- const extract = {
243
- args: [VArg, VArg, VArg, VArg],
244
- apply: (context, pipe, top, right, bottom, left) => {
245
- return pipe.extend({
246
- top,
247
- left,
248
- bottom,
249
- right,
250
- background: context.background
251
- });
252
- }
253
- };
254
- const rotate = {
255
- args: [VArg],
256
- apply: (_context, pipe, angel) => {
257
- return pipe.rotate(angel);
258
- }
259
- };
260
- const flip = {
261
- args: [],
262
- apply: (_context, pipe) => {
263
- return pipe.flip();
264
- }
265
- };
266
- const flop = {
267
- args: [],
268
- apply: (_context, pipe) => {
269
- return pipe.flop();
270
- }
271
- };
272
- const sharpen = {
273
- args: [VArg, VArg, VArg],
274
- apply: (_context, pipe, sigma, flat, jagged) => {
275
- return pipe.sharpen(sigma, flat, jagged);
276
- }
277
- };
278
- const median = {
279
- args: [VArg, VArg, VArg],
280
- apply: (_context, pipe, size) => {
281
- return pipe.median(size);
282
- }
283
- };
284
- const blur = {
285
- args: [VArg, VArg, VArg],
286
- apply: (_context, pipe) => {
287
- return pipe.blur();
288
- }
289
- };
290
- const flatten = {
291
- args: [VArg, VArg, VArg],
292
- apply: (context, pipe) => {
293
- return pipe.flatten({
294
- background: context.background
295
- });
296
- }
297
- };
298
- const gamma = {
299
- args: [VArg, VArg, VArg],
300
- apply: (_context, pipe, gamma2, gammaOut) => {
301
- return pipe.gamma(gamma2, gammaOut);
302
- }
303
- };
304
- const negate = {
305
- args: [VArg, VArg, VArg],
306
- apply: (_context, pipe) => {
307
- return pipe.negate();
308
- }
309
- };
310
- const normalize = {
311
- args: [VArg, VArg, VArg],
312
- apply: (_context, pipe) => {
313
- return pipe.normalize();
314
- }
315
- };
316
- const threshold = {
317
- args: [VArg],
318
- apply: (_context, pipe, threshold2) => {
319
- return pipe.threshold(threshold2);
320
- }
321
- };
322
- const modulate = {
323
- args: [VArg],
324
- apply: (_context, pipe, brightness, saturation, hue) => {
325
- return pipe.modulate({
326
- brightness,
327
- saturation,
328
- hue
329
- });
330
- }
331
- };
332
- const tint = {
333
- args: [VArg],
334
- apply: (_context, pipe, rgb) => {
335
- return pipe.tint(rgb);
336
- }
337
- };
338
- const grayscale = {
339
- args: [VArg],
340
- apply: (_context, pipe) => {
341
- return pipe.grayscale();
342
- }
343
- };
344
- const crop = extract;
345
- const q = quality;
346
- const b = background;
347
- const w = width;
348
- const h = height;
349
- const s = resize;
350
-
351
- const SUPPORTED_FORMATS = ["jpeg", "png", "webp", "avif", "tiff"];
352
- function createIPX(userOptions) {
353
- const defaults = {
354
- dir: getEnv("IPX_DIR", "."),
355
- domains: getEnv("IPX_DOMAINS", []),
356
- alias: getEnv("IPX_ALIAS", {}),
357
- sharp: {}
358
- };
359
- const options = defu__default['default'](userOptions, defaults);
360
- options.alias = Object.fromEntries(Object.entries(options.alias).map((e) => [ufo.withLeadingSlash(e[0]), e[1]]));
361
- const ctx = {
362
- sources: {}
363
- };
364
- if (options.dir) {
365
- ctx.sources.filesystem = createFilesystemSource({
366
- dir: options.dir
367
- });
368
- }
369
- if (options.domains) {
370
- ctx.sources.http = createHTTPSource({
371
- domains: options.domains
372
- });
373
- }
374
- return function ipx(id, modifiers = {}, reqOptions = {}) {
375
- if (!id) {
376
- throw createError("resource id is missing", 400);
377
- }
378
- id = ufo.hasProtocol(id) ? id : ufo.withLeadingSlash(id);
379
- for (const base in options.alias) {
380
- if (id.startsWith(base)) {
381
- id = ufo.joinURL(options.alias[base], id.substr(base.length));
382
- }
383
- }
384
- const getSrc = cachedPromise(() => {
385
- const source = ufo.hasProtocol(id) ? "http" : "filesystem";
386
- if (!ctx.sources[source]) {
387
- throw createError("Unknown source: " + source, 400);
388
- }
389
- return ctx.sources[source](id, reqOptions);
390
- });
391
- const getData = cachedPromise(async () => {
392
- const src = await getSrc();
393
- const data = await src.getData();
394
- const meta = imageMeta__default['default'](data);
395
- const mFormat = modifiers.f || modifiers.format;
396
- let format = mFormat || meta.type;
397
- if (format === "jpg") {
398
- format = "jpeg";
399
- }
400
- if (meta.type === "svg" && !mFormat) {
401
- return {
402
- data,
403
- format: "svg+xml",
404
- meta
405
- };
406
- }
407
- const animated = modifiers.animated !== void 0 || modifiers.a !== void 0;
408
- if (animated) {
409
- format = "webp";
410
- }
411
- let sharp = Sharp__default['default'](data, { animated });
412
- Object.assign(sharp.options, options.sharp);
413
- const handlers = Object.entries(modifiers).map(([name, args]) => ({ handler: getHandler(name), name, args })).filter((h) => h.handler).sort((a, b) => {
414
- const aKey = (a.handler.order || a.name || "").toString();
415
- const bKey = (b.handler.order || b.name || "").toString();
416
- return aKey.localeCompare(bKey);
417
- });
418
- const handlerCtx = {};
419
- for (const h of handlers) {
420
- sharp = applyHandler(handlerCtx, sharp, h.handler, h.args) || sharp;
421
- }
422
- if (SUPPORTED_FORMATS.includes(format)) {
423
- sharp = sharp.toFormat(format, {
424
- quality: handlerCtx.quality,
425
- progressive: format === "jpeg"
426
- });
427
- }
428
- const newData = await sharp.toBuffer();
429
- return {
430
- data: newData,
431
- format,
432
- meta
433
- };
434
- });
435
- return {
436
- src: getSrc,
437
- data: getData
438
- };
439
- };
440
- }
441
-
442
- async function _handleRequest(req, ipx) {
443
- const res = {
444
- statusCode: 200,
445
- statusMessage: "",
446
- headers: {},
447
- body: ""
448
- };
449
- const [modifiersStr = "", ...idSegments] = req.url.substr(1).split("/");
450
- const id = ufo.decode(idSegments.join("/"));
451
- if (!modifiersStr) {
452
- throw createError("Modifiers is missing in path: " + req.url, 400);
453
- }
454
- if (!id || id === "/") {
455
- throw createError("Resource id is missing: " + req.url, 400);
456
- }
457
- const modifiers = Object.create(null);
458
- if (modifiersStr !== "_") {
459
- for (const p of modifiersStr.split(",")) {
460
- const [key, value = ""] = p.split("_");
461
- modifiers[key] = ufo.decode(value);
462
- }
463
- }
464
- const img = ipx(id, modifiers, req.options);
465
- const src = await img.src();
466
- if (src.mtime) {
467
- if (req.headers["if-modified-since"]) {
468
- if (new Date(req.headers["if-modified-since"]) >= src.mtime) {
469
- res.statusCode = 304;
470
- return res;
471
- }
472
- }
473
- res.headers["Last-Modified"] = +src.mtime + "";
474
- }
475
- if (src.maxAge !== void 0) {
476
- res.headers["Cache-Control"] = `max-age=${+src.maxAge}, public, s-maxage=${+src.maxAge}`;
477
- }
478
- const { data, format } = await img.data();
479
- const etag = getEtag__default['default'](data);
480
- res.headers.ETag = etag;
481
- if (etag && req.headers["if-none-match"] === etag) {
482
- res.statusCode = 304;
483
- return res;
484
- }
485
- if (format) {
486
- res.headers["Content-Type"] = `image/${format}`;
487
- }
488
- res.body = data;
489
- return res;
490
- }
491
- function handleRequest(req, ipx) {
492
- return _handleRequest(req, ipx).catch((err) => {
493
- const statusCode = parseInt(err.statusCode) || 500;
494
- const statusMessage = err.statusMessage ? xss__default['default'](err.statusMessage) : `IPX Error (${statusCode})`;
495
- if (process.env.NODE_ENV !== "production" && statusCode === 500) {
496
- console.error(err);
497
- }
498
- return {
499
- statusCode,
500
- statusMessage,
501
- body: statusMessage,
502
- headers: {}
503
- };
504
- });
505
- }
506
- function createIPXMiddleware(ipx) {
507
- return function IPXMiddleware(req, res) {
508
- handleRequest({ url: req.url, headers: req.headers }, ipx).then((_res) => {
509
- res.statusCode = _res.statusCode;
510
- res.statusMessage = _res.statusMessage;
511
- for (const name in _res.headers) {
512
- res.setHeader(name, _res.headers[name]);
513
- }
514
- res.end(_res.body);
515
- });
516
- };
517
- }
518
-
519
- async function main() {
520
- const ipx = createIPX({});
521
- const middleware = createIPXMiddleware(ipx);
522
- await listhen.listen(middleware, {
523
- clipboard: false
524
- });
525
- }
526
- main().catch((err) => {
527
- consola__default['default'].error(err);
528
- process.exit(1);
529
- });