├── .gitignore ├── .npmignore ├── .npmrc ├── .nvmrc ├── .nycrc ├── .prettierignore ├── .prettierrc.json ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── bin ├── isteam └── isteamd ├── index.js ├── lib ├── helpers │ ├── dimension.js │ ├── get-hash-from-image.js │ ├── image-steps.js │ ├── image-type.js │ ├── index.js │ └── rgba.js ├── http │ ├── commands │ │ ├── colors.js │ │ ├── index.js │ │ └── info.js │ ├── connect.js │ ├── index.js │ ├── start-defaults.js │ ├── start.js │ ├── stop.js │ └── throttle.js ├── image.js ├── index.js ├── processor │ ├── index.js │ ├── processor-defaults.js │ ├── processor.js │ └── steps │ │ ├── blur.js │ │ ├── compression.js │ │ ├── crop.js │ │ ├── extend.js │ │ ├── flatten.js │ │ ├── flip.js │ │ ├── format.js │ │ ├── gamma.js │ │ ├── greyscale.js │ │ ├── index.js │ │ ├── lossless.js │ │ ├── metadata.js │ │ ├── normalize.js │ │ ├── progressive.js │ │ ├── quality.js │ │ ├── resize.js │ │ ├── rotate.js │ │ └── sharpen.js ├── router │ ├── index.js │ ├── router-defaults.js │ └── router.js ├── security │ ├── index.js │ ├── security-defaults.js │ └── security.js └── storage │ ├── fs │ └── index.js │ ├── http │ └── index.js │ ├── index.js │ ├── isteamb │ ├── 12mp.jpeg │ ├── 18mp.jpeg │ ├── 24mp.jpeg │ └── index.js │ └── storage-base.js ├── misc ├── node16-avif-bug.js ├── node16-bug.avif └── orientation-bug.js ├── package-lock.json ├── package.json ├── packages └── image-steam-bench │ ├── .gitignore │ ├── .npmrc │ ├── README.md │ ├── bin │ └── isteamb │ ├── docs │ └── dashboard.jpg │ ├── files │ ├── 12mp.jpeg │ ├── 18mp.jpeg │ └── 24mp.jpeg │ ├── package.json │ └── src │ ├── bench.js │ ├── cli.js │ ├── files.js │ ├── run-test.js │ ├── run.js │ ├── screen.js │ ├── server-process.js │ ├── server.js │ ├── test │ ├── cached.js │ ├── index.js │ ├── optimized.js │ ├── origin.js │ ├── real-90.js │ └── real-95.js │ ├── util │ ├── blind-request.js │ └── sleep.js │ └── verify-isteam.js ├── scripts ├── dev.js ├── invalid-sos.js ├── launch-coverage-in-browser.js ├── launch-demo-in-browser.js └── server.js └── test ├── .jshintignore ├── .jshintrc ├── commands.colors.tests.js ├── connect.tests.js ├── crop.tests.js ├── dimension.tests.js ├── files ├── A │ ├── UP_steam_loco.jpg │ └── big.jpeg ├── B │ ├── UP_steam_loco.jpg │ └── big.jpeg ├── Eiffel_Tower_Vertical.JPG ├── FM134-3青绿%20(速卖通不上)%20(4).jpg ├── Portrait_1.jpg ├── Portrait_2.jpg ├── Portrait_3.jpg ├── Portrait_4.jpg ├── Portrait_5.jpg ├── Portrait_6.jpg ├── Portrait_7.jpg ├── Portrait_8.jpg ├── UP_steam_loco.jpg ├── animated.gif ├── big.jpeg ├── close.svg ├── core-dump.png ├── favicon.png ├── gaines.jpg ├── icon.ico ├── image.svg ├── invalid-sos.jpg ├── isteam-arch.jpg ├── leds.webp ├── marbles-0004.tif ├── next.jpg ├── next2.jpg ├── not-an-image.jpg ├── roast.png ├── spider8k.png ├── steam-engine-2.jpg ├── steam-engine.jpg ├── steam-engine11.png ├── test.css ├── test.eot ├── test.js ├── test.otf ├── test.ttf ├── test.txt ├── test.woff ├── test.woff2 ├── tower-error.jpeg └── webp-too-large.png ├── image-server.config.js ├── image-server.etags.json ├── image-server.requests.js ├── image-server.tests.js ├── image-steps.tests.js ├── mocha.opts └── security.tests.js /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | *.DS_Store 11 | 12 | pids 13 | logs 14 | results 15 | 16 | npm-debug.log 17 | 18 | # Netbeans 19 | /nbproject/ 20 | 21 | # Webstorm Settings 22 | .idea/ 23 | !.idea/inspectionProfiles 24 | !.idea/jsLinters 25 | 26 | # Coverage 27 | coverage*/ 28 | .nyc_output/ 29 | 30 | # NPM Dependencies 31 | node_modules/ 32 | 33 | # Visual Studio files 34 | *.suo 35 | *.sln 36 | *.nsproj 37 | 38 | # Sublime Project files 39 | *.sublime-project 40 | *.sublime-workspace 41 | 42 | # Eclipse 43 | .project 44 | 45 | test/files/UP_steam_loco.jpg-* 46 | test/files/huge_file.jpg* 47 | test/cache/ 48 | test/cacheOptimized/ 49 | test/replica-cache/ 50 | test/replica-cacheOptimized/ 51 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | 15 | npm-debug.log 16 | 17 | # Netbeans 18 | /nbproject/ 19 | 20 | # Webstorm Settings 21 | .idea/ 22 | !.idea/inspectionProfiles 23 | !.idea/jsLinters 24 | 25 | # Coverage Output 26 | coverage*/ 27 | .nyc_output/ 28 | 29 | # NPM Dependencies 30 | node_modules/ 31 | package-lock.json 32 | 33 | # Visual Studio files 34 | *.suo 35 | *.sln 36 | *.nsproj 37 | 38 | # Sublime Project files 39 | *.sublime-project 40 | *.sublime-workspace 41 | 42 | # Eclipse 43 | .project 44 | 45 | test/ 46 | scripts/launch* 47 | scripts/dev* 48 | 49 | misc/ 50 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.12.0 2 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "nyc": { 3 | "all": true, 4 | "include": [ 5 | "test/**.js" 6 | ], 7 | "exclude": [ 8 | ], 9 | "reporter": [ 10 | "lcov" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.json 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | - '14' 5 | npm_args: '--no-optional' 6 | env: 7 | - TRAVIS="1" 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.64.2 - November 4 2022 2 | 3 | * ***(FIX)*** `sharp@0.31.2` resolves [rotation bug](https://github.com/lovell/sharp/issues/3391). 4 | * ***(FIX)*** Orientations for 5 & 7 were computed incorrectly. 5 | 6 | 7 | # 0.64.1 - October 22 2022 8 | 9 | * ***(FIX)*** Fix height calculation error for animated images. 10 | 11 | 12 | # 0.64.0 - October 14 2022 13 | 14 | * ***(ENHANCEMENT)*** Format (`fm`) now supports `gif` as output. 15 | 16 | 17 | # 0.63.2 - June 10 2022 18 | 19 | * ***(FIX)*** Rare crash fix when initializing `sharp` if invalid input. 20 | 21 | 22 | # 0.63.1 - June 9 2022 23 | 24 | * ***(FIX)*** `http` storage exception. 25 | 26 | 27 | # 0.63.0 - June 8 2022 28 | 29 | * ***(BREAKING)*** `saliency` was deprecated 2 years ago, and is now removed. 30 | 31 | 32 | # 0.62.0 - June 8 2022 33 | 34 | * ***(ENHANCEMENT)*** `fallback` storage option to support multi-tiered 35 | storage architectures. 36 | 37 | 38 | # 0.61.2 - February 2 2022 39 | 40 | * ***(SECURITY)*** Do not track "author". 41 | * ***(SECURITY)*** All dependencies updated with latest security patches. 42 | 43 | 44 | # 0.61.1 - January 3 2022 45 | 46 | * ***(SECURITY)*** All dependencies updated with latest security patches. 47 | * ***(CLEANUP)*** Removal of unused `jscs` & `jshint` deps, addition of `prettier`, 48 | and updates to Travis CI config. 49 | 50 | 51 | # 0.61.0 - November 30 2021 52 | 53 | * ***(ENHANCEMENT)*** Default resize limits increased from 2K to 4K resolution. Default 54 | optimized original size remains unchanged (2K), so this primarily benefits special 55 | usage of `?useOriginal=true`. 56 | * ***(BUG FIX)*** When `?useOriginal=true` is supplied the hash will reflect this 57 | modification to permit the same operations generating unique artifacts. Primarily 58 | benefits the above enhancement. 59 | 60 | 61 | # 0.60.0 - November 5 2021 62 | 63 | * ***(ENHANCEMENT)*** Support for `avif` compression format, enabled by default 64 | for supporting browsers. Optimized originals will remain `webp` for the time being. 65 | 66 | # 0.59.0 - May 31 2021 67 | 68 | * ***(ENHANCEMENT)*** Support for `route.beforeProcess` custom handler. 69 | 70 | 71 | # 0.56.1 - November 4 2020 72 | 73 | * ***(FIX)*** Images rotated prior to optimized original would result in optimized losing their 74 | orientation and resulting in unpredictable orientations. 75 | 76 | 77 | # 0.56.0 - June 9 2020 78 | 79 | * ***(MINOR FIX)*** Support for scoped driver options. This prevents reusing the same driver 80 | across apps to avoid polluting of options. This was not a problem with most pre-existing drivers, 81 | but will make things safer. 82 | 83 | 84 | # 0.55.0 - May 26 2020 85 | 86 | * ***(ENHANCEMENT)*** Option to set `isteamEndpoint=true` on the `http` storage client, allowing 87 | multiple regions to be chained together for speed and/or cost savings. This in effect permits 88 | multi-layered proxies to drastically reduce the volume of origin hits. 89 | 90 | 91 | # 0.54.0 - May 18 2020 92 | 93 | * ***(ENHANCEMENT)*** Sharp - Upgrade to latest Sharp (`0.25.3`). 94 | * ***(DEPRECATION)*** Saliency was always experimental. Now it's been deprecated and will be removed 95 | in future version. Warning provided at startup. 96 | * ***(CHANGE)*** `globalAgent` - Option is still adhered to, but no longer defaults to use 97 | `agentkeepalive` until explicitly provided. 98 | 99 | # 0.53.0 - April 27 2020 100 | 101 | * ***(FEATURE)*** Direct support for `isteamb` [driver](./lib/storage/isteamb), removing the need to use `http` proxy mode. 102 | 103 | 104 | # September 11 2019 105 | 106 | * ***(FEATURE)*** Full benchmark suite now available, [check it out](./packages/image-steam-bench)! `npm i -g image-steam-bench` 107 | 108 | # 0.51.0 - April 2019 109 | 110 | * ***(FEATURE)*** `router.hqOriginalSteps` - Support for highest quality optimized originals for smaller images (400x400 by default). Will only impact newly generated OO's. 111 | * ***(FEATURE)*** `lossless` - Option to enable lossless WebP via `/ll` path. 112 | 113 | 114 | # 0.50.0 - March 2019 115 | 116 | * ***(BREAKING)*** `embed` - Removal of deprecated function. 117 | * ***(ENHANCEMENT)*** Sharp - Upgrade to latest Sharp (`0.22.0`). 118 | 119 | 120 | # 0.49.0 - March 2019 121 | 122 | * ***(BREAKING)*** `background` is no longer a standalone image operation, which is not in a useful state anyway. 123 | * ***(FEATURE)*** `extend` - New operation allows extending the image. 124 | * ***(ENHANCEMENT)*** `resize.fit` - Resize now allows `fit` to be overridden. 125 | * ***(ENHANCEMENT)*** `resize.position` - Resize now allows `position` to be overridden. 126 | * ***(ENHANCEMENT)*** `resize.background` - Permits background to be applied to resize operation when applicable. 127 | * ***(FIX)*** `+/-` on percentage dimensions is now working. Was only working on fixed (px) dimensions prior. 128 | * ***(FIX)*** Various test fixes. 129 | 130 | 131 | # 0.48.0 - October 2018 132 | 133 | * ***(ENHANCEMENT)*** HTTP Agent - Utilize a more optimized HTTP(S) agent by default, including connection reuse. 134 | 135 | 136 | # 0.47.0 - October 2018 137 | 138 | * ***(ENHANCEMENT)*** Sharp - Upgrade to latest Sharp (`0.21.0`) for greater platform support. 139 | 140 | 141 | # 0.46.0 - June 2018 142 | 143 | * ***(ENHANCEMENT)*** Crop auto-focus - Greatly improved accuracy/consistency after being trained with hundreds of 144 | thousands of data points, which also allowed for the switch to a far more efficient (~10x) saliency mode (spectral). 145 | 146 | 147 | # 0.45.0 - May 2018 148 | 149 | * ***(CONFIGURATION)*** `router.supportWebP` - WebP may not be explicitly disabled, but remains enabled by default 150 | to avoid breakages. If performance is critical, disabling this option has been known to speed up image operations 151 | by 2 to 4 times. 152 | * ***(FIX)*** Saliency - Minor fixes to enabling/disabling this feature. 153 | 154 | 155 | # 0.44.0 - May 2018 156 | 157 | * ***(FEATURE)*** `$info` command - Returns all known information about the image, including saliency (new) if available. 158 | * ***(FEATURE)*** Crop auto-focus - An experimental new feature to permit saliency-based auto-focus. Exposed by crop anchor=`auto`. 159 | * ***(FEATURE)*** `$saliency` command - An experimental new feature to permit retrieving of saliency meta data. 160 | * ***(FEATURE)*** `$saliencyMap` command - An experimental new feature to permit retrieving of saliency map. 161 | * ***(DEPENDENCIES)*** `salient-autofocus` - Required by the new saliency auto-focus feature. 162 | 163 | 164 | # 0.43.0 - March 2018 165 | 166 | * ***(DEPENDENCIES)*** `sharp` - Updated to `v0.20` which requires `libvips` `v8.6.1` or later. 167 | 168 | 169 | # 0.42.0 - March 2018 170 | 171 | * ***(CONFIGURATION)*** `storage.cacheArtifacts` - Caching of image artifacts may now be disabled. 172 | 173 | 174 | # 0.41.0 - March 2018 175 | 176 | * ***(BREAKING)*** Default StorageOptions - Root of `storage` options may no longer include `StorageOptions` (options supplied to storage driver), and instead must supply to `storage.defaults` instead. This is a necessary change to avoid polluting the options supplied to storage drivers. 177 | * ***(BREAKING)*** S3 Storage - Client moved to its own repo: https://github.com/asilvas/image-steam-s3 178 | * ***(BREAKING)*** Node - Version 6 and later required. 179 | * ***(PERFORMANCE)*** `storage.cacheTTS` & `storage.cacheOptimizedTTS` - Added to support "refreshing" of stale objects in cache to avoid needless reprocessing of images in caches with time-to-live set. 180 | * ***(CONFIGURATION)*** `storage.cacheOptimized` - Added a new caching option to allow discrete `StorageOptions` supplied only for optimized original caching. This permits splitting of cache for sake of replication or eviction policies. 181 | * ***(CONFIGURATION)*** `isDefaults` - Commandline argument `isDefaults` was added to allow merging of your own defaults. 182 | * ***(GEO)*** `storage.replicas` - Tunable cache replication beyond what you'd get from storage-native replication settings. Allows for more flexible architectures that span multiple regions. 183 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Aaron Silvas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/isteam: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../').http.start(); 4 | 5 | -------------------------------------------------------------------------------- /bin/isteamd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var path = require("path"); 4 | var spawn = require("child_process").spawn; 5 | var args = process.argv.slice(2); 6 | var binPath = path.resolve(__dirname, '..', 'bin', 'isteam'); 7 | var proc; 8 | 9 | proc = spawn(process.execPath, args, { 10 | stdio: "ignore", 11 | detached: true 12 | }); 13 | 14 | proc.on('exit', function (code) { 15 | console.error('image-steam exited: ' + code); 16 | }); 17 | 18 | proc.unref(); // unreference so we may exit 19 | 20 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); 2 | -------------------------------------------------------------------------------- /lib/helpers/dimension.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | getXAspect: getXAspect, 3 | getYAspect: getYAspect, 4 | getInfo: getInfo, 5 | resolveStep: resolveStep, 6 | }; 7 | 8 | function resolveStep(originalImage, imageStep) { 9 | var info; 10 | 11 | if (typeof imageStep.width === 'string') { 12 | info = getInfo(imageStep.width); 13 | 14 | if (info.unit === '%') { 15 | if (info.modifier === '+') { 16 | imageStep.width = originalImage.info.width; 17 | } else if (info.modifier === '-') { 18 | imageStep.width = 19 | originalImage.info.width - 20 | parseInt(originalImage.info.width * (info.value / 100)); 21 | } else { 22 | imageStep.width = parseInt( 23 | originalImage.info.width * (info.value / 100) 24 | ); 25 | } 26 | } else { 27 | // px 28 | if (info.modifier === '+') { 29 | imageStep.width = originalImage.info.width + info.value; 30 | } else if (info.modifier === '-') { 31 | imageStep.width = originalImage.info.width - info.value; 32 | } else { 33 | imageStep.width = info.value; 34 | } 35 | } 36 | } 37 | 38 | if (typeof imageStep.height === 'string') { 39 | info = getInfo(imageStep.height); 40 | 41 | if (info.unit === '%') { 42 | if (info.modifier === '+') { 43 | imageStep.height = originalImage.info.height; 44 | } else if (info.modifier === '-') { 45 | imageStep.height = 46 | originalImage.info.height - 47 | parseInt(originalImage.info.height * (info.value / 100)); 48 | } else { 49 | imageStep.height = parseInt( 50 | originalImage.info.height * (info.value / 100) 51 | ); 52 | } 53 | } else { 54 | // px 55 | if (info.modifier === '+') { 56 | imageStep.height = originalImage.info.height + info.value; 57 | } else if (info.modifier === '-') { 58 | imageStep.height = originalImage.info.height - info.value; 59 | } else { 60 | imageStep.height = info.value; 61 | } 62 | } 63 | } 64 | 65 | if (typeof imageStep.left === 'string') { 66 | info = getInfo(imageStep.left); 67 | if (info.unit === '%') { 68 | if (info.modifier === '-') { 69 | imageStep.left = parseInt( 70 | originalImage.info.width * (info.value / 100) * -1 71 | ); 72 | } else { 73 | imageStep.left = parseInt( 74 | originalImage.info.width * (info.value / 100) 75 | ); 76 | } 77 | } else { 78 | // px 79 | if (info.modifier === '-') { 80 | imageStep.left = 0 - info.value; 81 | } else { 82 | imageStep.left = info.value; 83 | } 84 | } 85 | } 86 | 87 | if (typeof imageStep.top === 'string') { 88 | info = getInfo(imageStep.top); 89 | 90 | if (info.unit === '%') { 91 | if (info.modifier === '-') { 92 | imageStep.top = parseInt( 93 | originalImage.info.height * (info.value / 100) * -1 94 | ); 95 | } else { 96 | imageStep.top = parseInt( 97 | originalImage.info.height * (info.value / 100) 98 | ); 99 | } 100 | } else { 101 | // px 102 | if (info.modifier === '-') { 103 | imageStep.top = 0 - info.value; 104 | } else { 105 | imageStep.top = info.value; 106 | } 107 | } 108 | } 109 | 110 | if (typeof imageStep.right === 'string') { 111 | info = getInfo(imageStep.right); 112 | 113 | if (info.unit === '%') { 114 | if (info.modifier === '+') { 115 | imageStep.right = parseInt( 116 | originalImage.info.width + 117 | originalImage.info.width * (info.value / 100) 118 | ); 119 | } else if (info.modifier === '-') { 120 | imageStep.right = parseInt( 121 | originalImage.info.width - 122 | originalImage.info.width * (info.value / 100) 123 | ); 124 | } else { 125 | imageStep.right = parseInt( 126 | originalImage.info.width * (info.value / 100) 127 | ); 128 | } 129 | } else { 130 | // px 131 | if (info.modifier === '+') { 132 | imageStep.right = originalImage.info.width + info.value; 133 | } else if (info.modifier === '-') { 134 | imageStep.right = originalImage.info.width - info.value; 135 | } else { 136 | imageStep.right = info.value; 137 | } 138 | } 139 | } 140 | 141 | if (typeof imageStep.bottom === 'string') { 142 | info = getInfo(imageStep.bottom); 143 | 144 | if (info.unit === '%') { 145 | if (info.modifier === '+') { 146 | imageStep.bottom = parseInt( 147 | originalImage.info.height + 148 | originalImage.info.height * (info.value / 100) 149 | ); 150 | } else if (info.modifier === '-') { 151 | imageStep.bottom = parseInt( 152 | originalImage.info.height - 153 | originalImage.info.height * (info.value / 100) 154 | ); 155 | } else { 156 | imageStep.bottom = parseInt( 157 | originalImage.info.height * (info.value / 100) 158 | ); 159 | } 160 | } else { 161 | // px 162 | if (info.modifier === '+') { 163 | imageStep.bottom = originalImage.info.height + info.value; 164 | } else if (info.modifier === '-') { 165 | imageStep.bottom = originalImage.info.height - info.value; 166 | } else { 167 | imageStep.bottom = info.value; 168 | } 169 | } 170 | } 171 | 172 | var hasAnchor = typeof imageStep.anchor === 'string', 173 | hasAnchorX = typeof imageStep.anchorX === 'string', 174 | hasAnchorY = typeof imageStep.anchorY === 'string'; 175 | 176 | if (imageStep.anchor !== 'auto' && (hasAnchor || hasAnchorX || hasAnchorY)) { 177 | var anchorCenter = convertAnchorCenter(imageStep, originalImage); 178 | 179 | if (hasAnchorX) { 180 | info = getInfo(imageStep.anchorX); 181 | var anchorXPixels = 182 | info.unit === '%' 183 | ? Math.floor((originalImage.info.width * info.value) / 100) 184 | : info.value; 185 | if (info.modifier === '+') { 186 | imageStep.anchorX = anchorCenter.x + anchorXPixels; 187 | } else if (info.modifier === '-') { 188 | imageStep.anchorX = anchorCenter.x - anchorXPixels; 189 | } else { 190 | imageStep.anchorX = anchorXPixels; 191 | } 192 | } else { 193 | imageStep.anchorX = anchorCenter.x; 194 | } 195 | 196 | if (hasAnchorY) { 197 | info = getInfo(imageStep.anchorY); 198 | var anchorYPixels = 199 | info.unit === '%' 200 | ? Math.floor((originalImage.info.height * info.value) / 100) 201 | : info.value; 202 | if (info.modifier === '+') { 203 | imageStep.anchorY = anchorCenter.y + anchorYPixels; 204 | } else if (info.modifier === '-') { 205 | imageStep.anchorY = anchorCenter.y - anchorYPixels; 206 | } else { 207 | imageStep.anchorY = anchorYPixels; 208 | } 209 | } else { 210 | imageStep.anchorY = anchorCenter.y; 211 | } 212 | } 213 | } 214 | 215 | function getInfo(value) { 216 | var unit = /\%$/.test(value) ? '%' : 'px'; 217 | var decMatch = 218 | unit === '%' ? value.match(/(\d+(\.\d+)?)|(\.\d+)/) : value.match(/\d+/); 219 | var parsedVal = (decMatch && parseFloat(decMatch[0])) || 0; 220 | 221 | return { 222 | unit: unit, 223 | modifier: value[0] === '+' ? '+' : value[0] === '-' ? '-' : null, 224 | value: parsedVal, 225 | }; 226 | } 227 | 228 | function getXAspect(image) { 229 | return image.info.width / image.info.height; 230 | } 231 | 232 | function getYAspect(image) { 233 | return image.info.height / image.info.width; 234 | } 235 | 236 | function convertAnchorCenter(imageStep, originalImage) { 237 | var anchor = 238 | typeof imageStep.anchor === 'string' && imageStep.anchor.length === 2 239 | ? imageStep.anchor 240 | : 'cc', 241 | convertedAnchor = { x: 0, y: 0 }, 242 | height = imageStep.height || originalImage.info.height, 243 | width = imageStep.width || originalImage.info.width; 244 | 245 | switch (anchor[0]) { 246 | case 't': 247 | convertedAnchor.y = Math.floor(height / 2); 248 | break; 249 | case 'b': 250 | convertedAnchor.y = Math.floor(originalImage.info.height - height / 2); 251 | break; 252 | default: 253 | convertedAnchor.y = Math.floor(originalImage.info.height / 2); 254 | break; 255 | } 256 | 257 | switch (anchor[1]) { 258 | case 'l': 259 | convertedAnchor.x = Math.floor(width / 2); 260 | break; 261 | case 'r': 262 | convertedAnchor.x = Math.floor(originalImage.info.width - width / 2); 263 | break; 264 | default: 265 | convertedAnchor.x = Math.floor(originalImage.info.width / 2); 266 | break; 267 | } 268 | 269 | return convertedAnchor; 270 | } 271 | -------------------------------------------------------------------------------- /lib/helpers/get-hash-from-image.js: -------------------------------------------------------------------------------- 1 | var XXHash = require('xxhash'); 2 | 3 | module.exports = getHashFromImage; 4 | 5 | function getHashFromImage(image) { 6 | // IMPORTANT! DO NOT EVER CHANGE MY SEED VALUE UNLESS YOU WANT TO INVALIDATE 7 | // EXISTING PROCESSED IMAGES! 8 | return XXHash.hash(image.buffer, 0xabcd1133); 9 | } 10 | -------------------------------------------------------------------------------- /lib/helpers/image-steps.js: -------------------------------------------------------------------------------- 1 | var XXHash = require('xxhash'); 2 | 3 | module.exports = { 4 | getHashFromSteps: getHashFromSteps, 5 | }; 6 | 7 | function getHashFromSteps(imageSteps) { 8 | // IMPORTANT! DO NOT EVER CHANGE MY SEED VALUE UNLESS YOU WANT TO INVALIDATE 9 | // EXISTING PROCESSED IMAGES! 10 | return XXHash.hash(Buffer.from(JSON.stringify(imageSteps)), 0xabcd1133); 11 | } 12 | -------------------------------------------------------------------------------- /lib/helpers/image-type.js: -------------------------------------------------------------------------------- 1 | var sharp = require('sharp'); 2 | 3 | module.exports = function (image, cb) { 4 | sharp(image.buffer).metadata(function (err, metadata) { 5 | if (err) return void cb(err); 6 | switch (metadata.format) { 7 | case 'jpeg': 8 | cb(null, 'image/jpeg'); 9 | break; 10 | case 'png': 11 | cb(null, 'image/png'); 12 | break; 13 | case 'gif': 14 | cb(null, 'image/gif'); 15 | break; 16 | default: 17 | cb(); 18 | } 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /lib/helpers/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | dimension: require('./dimension'), 3 | getHashFromImage: require('./get-hash-from-image'), 4 | imageSteps: require('./image-steps'), 5 | imageType: require('./image-type'), 6 | rgba: require('./rgba'), 7 | }; 8 | -------------------------------------------------------------------------------- /lib/helpers/rgba.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | getRGBA: getRGBA, 3 | }; 4 | 5 | function getRGBA(imageStep) { 6 | if (!imageStep.background) return null; 7 | 8 | // type(v1[; v2][; v3][; v4]) 9 | var match = /^([^\(].*)\(([^\)].*)\)$/.exec(imageStep.background); 10 | if (!match || match.length !== 3) 11 | throw new Error('Invalid `background` value'); 12 | var type = match[1]; 13 | var value = match[2]; 14 | 15 | var rgba; 16 | 17 | if (type === 'hex') { 18 | var hex = parseInt(value, 16); 19 | rgba = { 20 | r: hex >> 16, 21 | g: (hex >> 8) & 255, 22 | b: hex & 255, 23 | alpha: 1, 24 | }; 25 | } else if (type === 'rgb' || type === 'rgba') { 26 | var rgbaValues = value.split(';').map(parseFloat); 27 | 28 | rgba = { 29 | r: rgbaValues[0] || 0, 30 | g: rgbaValues[1] || 0, 31 | b: rgbaValues[2] || 0, 32 | alpha: rgbaValues.length === 4 ? rgbaValues[3] : 1, 33 | }; 34 | } else { 35 | throw new Error('Invalid `background` value'); 36 | } 37 | 38 | return rgba; 39 | } 40 | -------------------------------------------------------------------------------- /lib/http/commands/colors.js: -------------------------------------------------------------------------------- 1 | const getColors = require('image-pal-sharp/lib/hsluv'); 2 | 3 | module.exports = (command, image, reqInfo, req, res, cb) => { 4 | const options = { 5 | srcBuffer: image.buffer, 6 | width: command.width 7 | ? Math.max(20, Math.min(200, parseInt(command.width))) 8 | : 100, 9 | height: command.height 10 | ? Math.max(20, Math.min(200, parseInt(command.height))) 11 | : null, 12 | maxColors: command.maxColors 13 | ? Math.max(2, Math.min(32, parseInt(command.maxColors))) 14 | : 10, 15 | cubicCells: command.cubicCells 16 | ? Math.max(3, Math.min(4, parseInt(command.cubicCells))) 17 | : 4, 18 | mean: command.mean === 'false' ? false : true, 19 | order: command.order === 'density' ? 'density' : 'distance', 20 | }; 21 | 22 | getColors(options, (err, colors) => { 23 | if (err) { 24 | res.writeHead(400); 25 | res.end(); 26 | return void cb && cb(err); 27 | } 28 | 29 | res.writeHead(200, { 'Content-Type': 'application/json' }); 30 | res.end( 31 | JSON.stringify({ 32 | colors: colors, 33 | }) 34 | ); 35 | 36 | cb && cb(null, colors); 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /lib/http/commands/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | colors: require('./colors'), 3 | info: require('./info'), 4 | }; 5 | -------------------------------------------------------------------------------- /lib/http/commands/info.js: -------------------------------------------------------------------------------- 1 | module.exports = function (command, image, reqInfo, req, res, cb) { 2 | res.writeHead(200, { 'Content-Type': 'application/json' }); 3 | res.end( 4 | JSON.stringify({ 5 | info: image.info, 6 | }) 7 | ); 8 | 9 | cb && cb(); 10 | }; 11 | -------------------------------------------------------------------------------- /lib/http/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | start: require('./start'), 3 | stop: require('./stop'), 4 | Connect: require('./connect'), 5 | }; 6 | -------------------------------------------------------------------------------- /lib/http/start-defaults.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | http: { 3 | port: 13337, 4 | host: 'localhost', 5 | backlog: 511, 6 | }, 7 | log: { 8 | errors: true, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /lib/http/start.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const https = require('https'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const _ = require('lodash'); 6 | const Connect = require('./connect'); 7 | const argv = require('yargs').argv; 8 | const defaults = require('./start-defaults'); 9 | const HttpAgent = require('agentkeepalive'); 10 | const HttpsAgent = HttpAgent.HttpsAgent; 11 | 12 | module.exports = start; 13 | 14 | function start(options) { 15 | var config = _.merge({}, defaults, options || {}); 16 | 17 | if (typeof argv.isDefaults === 'string') { 18 | config = _.merge(config, require(path.resolve(argv.isDefaults))); 19 | } 20 | 21 | if (typeof argv.isConfig === 'string') { 22 | config = _.merge(config, require(path.resolve(argv.isConfig))); 23 | } 24 | 25 | process.on('SIGINT', function () { 26 | // force exit, to prevent open handles from keeping the process open 27 | setTimeout(process.exit, 1000).unref(); // do not let timeout keep process open 28 | }); 29 | 30 | return startServers(config); 31 | } 32 | 33 | function startServers(config) { 34 | var servers; 35 | 36 | if (Array.isArray(config.http) === true) { 37 | servers = []; // array to support multiple binds 38 | config.http.forEach(function (httpConfig) { 39 | servers.push(startServer(config, httpConfig)); 40 | }); 41 | } else { 42 | servers = startServer(config, config.http); 43 | } 44 | 45 | return servers; 46 | } 47 | 48 | function startServer(config, httpConfig) { 49 | if (typeof httpConfig.globalAgent !== 'undefined') { 50 | http.globalAgent = _.isPlainObject(httpConfig.globalAgent) 51 | ? new HttpAgent(httpConfig.globalAgent) 52 | : httpConfig.globalAgent; 53 | https.globalAgent = _.isPlainObject(httpConfig.globalAgent) 54 | ? new HttpsAgent(httpConfig.globalAgent) 55 | : httpConfig.globalAgent; 56 | } 57 | 58 | if (httpConfig.ssl) { 59 | if (typeof httpConfig.ssl.pfx === 'string') { 60 | httpConfig.ssl.pfx = fs.readFileSync(httpConfig.ssl.pfx, 'utf8'); 61 | } 62 | if (typeof httpConfig.ssl.key === 'string') { 63 | httpConfig.ssl.key = fs.readFileSync(httpConfig.ssl.key, 'utf8'); 64 | } 65 | if (typeof httpConfig.ssl.cert === 'string') { 66 | httpConfig.ssl.cert = fs.readFileSync(httpConfig.ssl.cert, 'utf8'); 67 | } 68 | } 69 | 70 | var processRequest = new Connect(config); 71 | processRequest.on('error', function (err) { 72 | if (config.log.errors) { 73 | console.error( 74 | 'ERR:', 75 | new Date().toISOString(), 76 | err.method || '', 77 | err.url || '' 78 | ); 79 | console.error(err.stack || err); 80 | } 81 | }); 82 | processRequest.on('warn', function (err) { 83 | if (config.log.warnings) { 84 | console.warn( 85 | 'WARN:', 86 | new Date().toISOString(), 87 | err.method || '', 88 | err.url || '' 89 | ); 90 | console.warn(err.stack || err); 91 | } 92 | }); 93 | 94 | var server = httpConfig.ssl 95 | ? https.createServer(httpConfig.ssl, processRequest.getHandler()) 96 | : http.createServer(processRequest.getHandler()); 97 | server.isteam = processRequest; 98 | 99 | server.on('error', function (err) { 100 | console.error('image-steam> http(s) error:', err.stack || err); 101 | }); 102 | 103 | server.listen( 104 | httpConfig.port, 105 | httpConfig.host, 106 | httpConfig.backlog, 107 | function (err) { 108 | if (!err) { 109 | console.log( 110 | 'Server running at', 111 | httpConfig.ssl 112 | ? 'https://' 113 | : 'http://' + 114 | (httpConfig.host || 'localhost') + 115 | ':' + 116 | httpConfig.port 117 | ); 118 | } 119 | } 120 | ); 121 | 122 | return server; 123 | } 124 | -------------------------------------------------------------------------------- /lib/http/stop.js: -------------------------------------------------------------------------------- 1 | module.exports = stop; 2 | 3 | function stop(servers) { 4 | if (Array.isArray(servers) === true) { 5 | servers.forEach(function (server) { 6 | server.close(); 7 | }); 8 | } else { 9 | servers.close(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/http/throttle.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events').EventEmitter; 2 | const sem = require('semaphore'); 3 | 4 | module.exports = Throttle; 5 | 6 | function Throttle(options) { 7 | if (!(this instanceof Throttle)) { 8 | return new Throttle(options); 9 | } 10 | 11 | EventEmitter.call(this); 12 | 13 | options = options || {}; 14 | options.ccProcessors = options.ccProcessors || 4; 15 | options.ccPrefetchers = options.ccPrefetchers || 20; 16 | options.ccRequests = options.ccRequests || 100; 17 | options.responseTimeoutMs = options.responseTimeoutMs || 120000; 18 | this.sem_ccProcessors = sem(options.ccProcessors); 19 | this.sem_ccPrefetchers = sem(options.ccPrefetchers); 20 | this.ccRequests = 0; 21 | this.options = options; 22 | } 23 | 24 | var p = (Throttle.prototype = new EventEmitter()); 25 | 26 | p.startRequest = function (req, res) { 27 | if (this.ccRequests >= this.options.ccRequests) { 28 | return false; 29 | } 30 | 31 | const end = res.end; 32 | const $this = this; 33 | this.ccRequests++; 34 | req.imageSteam = { endCount: 0, prefetchers: 0, processors: 0 }; 35 | 36 | var cleanupTimer = setTimeout(() => { 37 | this.emit('warn', 'res.end NEVER called, cleaning up!'); 38 | try { 39 | res.writeHead(408); 40 | } catch (ex) {} // eat it 41 | res.end(); 42 | }, this.options.responseTimeoutMs); 43 | 44 | res.end = function () { 45 | if (req.imageSteam.endCount !== 0) { 46 | $this.emit('warn', 'res.end invoked more than once!'); 47 | // THIS IS ALLOWED AS WE MUST ALWAYS ALLOWING FREEING OF 48 | // NEWLY ALLOCATED SEATS. 49 | } else { 50 | // only perform these tasks once EVER 51 | clearTimeout(cleanupTimer); 52 | $this.ccRequests--; 53 | } 54 | req.imageSteam.endCount++; 55 | if (req.imageSteam.prefetchers > 0) { 56 | req.imageSteam.prefetchers = 0; 57 | $this.sem_ccPrefetchers.leave(req.imageSteam.prefetchers); 58 | } 59 | if (req.imageSteam.processors > 0) { 60 | req.imageSteam.processors = 0; 61 | $this.sem_ccProcessors.leave(req.imageSteam.processors); 62 | } 63 | end.apply(res, arguments); 64 | }.bind(res); 65 | 66 | return true; 67 | }; 68 | 69 | p.getPrefetcher = function (req, cb) { 70 | if (!req.imageSteam) { 71 | throw new Error('Cannot getPrefetcher without calling startRequest'); 72 | } 73 | this.sem_ccPrefetchers.take(() => { 74 | req.imageSteam.prefetchers++; 75 | // CRITICAL! If res.end has already been called, we MUST 76 | // auto-release the seats. Typically happens due to 77 | // timeout before semaphore releasing leases. 78 | if (req.imageSteam.endCount > 0) { 79 | // release leases 80 | this.sem_ccPrefetchers.leave(req.imageSteam.prefetchers); 81 | req.imageSteam.prefetchers = 0; 82 | return void cb( 83 | new Error('Expired request has auto-released prefetch leases') 84 | ); 85 | } 86 | cb(); 87 | }); 88 | }; 89 | 90 | p.getProcessor = function (req, cb) { 91 | if (!req.imageSteam) { 92 | throw new Error('Cannot getProcessor without calling startRequest'); 93 | } 94 | this.sem_ccProcessors.take(() => { 95 | req.imageSteam.processors++; 96 | // CRITICAL! If res.end has already been called, we MUST 97 | // auto-release the seats. Typically happens due to 98 | // timeout before semaphore releasing leases. 99 | if (req.imageSteam.endCount > 0) { 100 | // release leases 101 | this.sem_ccProcessors.leave(req.imageSteam.processors); 102 | req.imageSteam.processors = 0; 103 | return void cb( 104 | new Error('Expired request has auto-released processor leases') 105 | ); 106 | } 107 | cb(); 108 | }); 109 | }; 110 | -------------------------------------------------------------------------------- /lib/image.js: -------------------------------------------------------------------------------- 1 | module.exports = Image; 2 | 3 | function Image(info, buffer) { 4 | this.info = info || {}; 5 | this.buffer = buffer; 6 | } 7 | 8 | var p = Image.prototype; 9 | 10 | Object.defineProperty(p, 'contentType', { 11 | get: function () { 12 | return this.info && 'image/' + this.info.format; 13 | }, 14 | }); 15 | 16 | Object.defineProperty(p, 'ETag', { 17 | get: function () { 18 | return this.info && this.info.hash && this.info.hash.toString(); 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | http: require('./http'), 3 | router: require('./router'), 4 | Image: require('./image'), 5 | storage: require('./storage'), 6 | processor: require('./processor'), 7 | security: require('./security'), 8 | throttle: require('./http/throttle'), 9 | }; 10 | -------------------------------------------------------------------------------- /lib/processor/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./processor'); 2 | -------------------------------------------------------------------------------- /lib/processor/processor-defaults.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | maxSize: { width: 3840, height: 3840 }, // 4K 3 | }; 4 | -------------------------------------------------------------------------------- /lib/processor/processor.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter; 2 | var async = require('async'); 3 | var sharp = require('sharp'); 4 | var _ = require('lodash'); 5 | var helpers = require('../helpers'); 6 | var Image = require('../image'); 7 | var stepProcessors = require('./steps'); 8 | var defaults = require('./processor-defaults'); 9 | const { imageSteps } = require('../helpers'); 10 | 11 | module.exports = Processor; 12 | 13 | function Processor(options) { 14 | if (!(this instanceof Processor)) { 15 | return new Processor(options); 16 | } 17 | 18 | EventEmitter.call(this); 19 | 20 | this.options = _.merge({}, defaults, options || {}); 21 | 22 | if (this.options.sharp) { 23 | if ('cache' in this.options.sharp) { 24 | sharp.cache(this.options.sharp.cache); 25 | } 26 | if ('concurrency' in this.options.sharp) { 27 | sharp.concurrency(this.options.sharp.concurrency); 28 | } 29 | if ('simd' in this.options.sharp) { 30 | sharp.concurrency(this.options.sharp.simd); 31 | } 32 | if ('defaults' in this.options.sharp) { 33 | this.defaults = this.options.sharp.defaults; 34 | } 35 | } 36 | } 37 | 38 | var p = (Processor.prototype = new EventEmitter()); 39 | 40 | p.process = function (originalImage, imageSteps, options, storageOptions, cb) { 41 | if (typeof options === 'function') { 42 | cb = options; 43 | options = {}; 44 | } 45 | options = options || {}; 46 | let sharpI; 47 | try { 48 | sharpI = sharp(originalImage.buffer, this.defaults); 49 | } catch (err) { 50 | return cb(err); 51 | } 52 | var context = { 53 | options: _.merge({}, this.options, storageOptions), 54 | originalImage: originalImage, 55 | formatOptions: {}, 56 | imageSteps: imageSteps, 57 | hqOriginalSteps: options.hqOriginalSteps, 58 | hqOriginalMaxPixels: options.hqOriginalMaxPixels, 59 | processedImage: new Image(originalImage.info), 60 | sharp: sharpI, 61 | }; 62 | 63 | var tasks = [ 64 | getMetaDataTask(context, context.originalImage), 65 | getProcessorTask(context, imageSteps), 66 | ]; 67 | 68 | async.series(tasks, function (err, results) { 69 | if (err) { 70 | return void cb(err); 71 | } 72 | 73 | cb(null, context.processedImage); 74 | }); 75 | }; 76 | 77 | function getMetaDataTask(context, image) { 78 | return function (cb) { 79 | context.sharp.metadata(function (err, metadata) { 80 | if (err) { 81 | return void cb(err); 82 | } 83 | 84 | delete metadata.exif; 85 | delete metadata.icc; 86 | delete metadata.iptc; 87 | delete metadata.xmp; 88 | image.info = _.merge(image.info, metadata); 89 | if (image.info.pages && image.info.pageHeight) { 90 | // backward compatibility to support animated images 91 | image.info.height = image.info.pageHeight; 92 | } 93 | cb(null, metadata); 94 | }); 95 | }; 96 | } 97 | 98 | function getProcessorTask(context, imageSteps) { 99 | return function (cb) { 100 | if (!imageSteps || !imageSteps.length) { 101 | return void cb(null, context.originalImage); 102 | } 103 | 104 | if ( 105 | context.hqOriginalSteps && 106 | context.originalImage.info.width * context.originalImage.info.height <= 107 | context.hqOriginalMaxPixels 108 | ) { 109 | // if hq steps are provided, use them only if the size of the original is <= that of allowed high-quality settings 110 | imageSteps = context.hqOriginalSteps; 111 | } 112 | 113 | if (context.originalImage.info.hasAlpha) { 114 | imageSteps.forEach(function (step) { 115 | if (step.name === 'format' && step.format === 'jpeg') { 116 | step.format = 'png'; // retain alpha 117 | } 118 | }); 119 | } 120 | 121 | try { 122 | imageSteps.forEach(function (step) { 123 | var stepProcessor = stepProcessors[step.name]; 124 | if (!stepProcessor) return; // do not process 125 | stepProcessor(context, step, imageSteps); 126 | }); 127 | } catch (ex) { 128 | return void cb(ex); 129 | } 130 | 131 | context.sharp.toBuffer(function (err, outputBuffer, info) { 132 | if (err) { 133 | return void cb(err); 134 | } 135 | 136 | context.processedImage.buffer = outputBuffer; 137 | context.processedImage.info.hash = helpers.getHashFromImage( 138 | context.processedImage 139 | ); 140 | context.processedImage.info.byteSize = outputBuffer.length; 141 | cb(null, context.processedImage); 142 | }); 143 | }; 144 | } 145 | 146 | /* potential future stuff to support batches (multiple processors and step chaining) 147 | function getTask(context, imageStep) { 148 | var newBatchRequired = !context.batch // no batch 149 | || imageStep.preserveOrder // if order must be preserved, always use fresh batch 150 | || (context.batch && context.batch.processor !== imageStep.processor) // if processor differs from current batch 151 | || imageStep.name in context.batch.operations // if operation has already been issued to processor 152 | ; 153 | var batch = newBatchRequired ? batchBegin(context.bufferSrc, imageStep.processor) : context.batch; 154 | if (newBatchRequired && !context.batch) { 155 | // end prior batch 156 | } 157 | 158 | var task = null; 159 | 160 | if (newBatchRequired) { 161 | // only create new task if new batch is required 162 | task = (function(batch) { 163 | var cb = function() { 164 | // TODO 165 | }; 166 | return function(asyncCb) { 167 | // TODO 168 | }; 169 | })(batch); 170 | } 171 | 172 | if (imageStep.preserveOrder === true) { 173 | context.batch = null; 174 | 175 | // end new batch 176 | batchEnd(batch); 177 | } else { 178 | context.batch = 179 | } 180 | 181 | return task; 182 | } 183 | 184 | function batchBegin(buffer, processorName) { 185 | var batch = { 186 | processorName: processorName, 187 | cb: null, // TODO 188 | operations: {} 189 | }; 190 | 191 | switch (processorName) { 192 | case 'sharp': 193 | batch.processor = sharp(buffer); 194 | break; 195 | default: 196 | throw new Error('Unrecognized processor ' + processorName); 197 | } 198 | 199 | return batch; 200 | } 201 | 202 | function batchEnd(batch) { 203 | if (!batch) { 204 | throw new Error('Cannot end a non-batch'); 205 | } 206 | 207 | var bufferDst = new Buffer(); // todo 208 | switch (batch.processorName) { 209 | case 'sharp': 210 | batch.processor.toBuffer(bufferDst, batch.cb); 211 | break; 212 | } 213 | } 214 | */ 215 | -------------------------------------------------------------------------------- /lib/processor/steps/blur.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | stepInfo.sigma = parseFloat(stepInfo.sigma) || 2.0; 3 | if (stepInfo.sigma < 0.3) { 4 | stepInfo.sigma = 0.3; 5 | } else if (stepInfo.sigma > 1000) { 6 | stepInfo.sigma = 1000; 7 | } 8 | 9 | context.sharp.blur(stepInfo.sigma); 10 | }; 11 | -------------------------------------------------------------------------------- /lib/processor/steps/compression.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | context.formatOptions.compressionLevel = 3 | (stepInfo.compression && parseInt(stepInfo.compression)) || 6; 4 | }; 5 | -------------------------------------------------------------------------------- /lib/processor/steps/crop.js: -------------------------------------------------------------------------------- 1 | var helpers = require('../../helpers'); 2 | var _ = require('lodash'); 3 | 4 | module.exports = function (context, stepInfo) { 5 | var img = context.processedImage; 6 | helpers.dimension.resolveStep(img, stepInfo); 7 | var anchorX = isNaN(stepInfo.anchorX) 8 | ? Math.floor(img.info.width / 2) 9 | : stepInfo.anchorX, 10 | anchorY = isNaN(stepInfo.anchorY) 11 | ? Math.floor(img.info.height / 2) 12 | : stepInfo.anchorY; 13 | 14 | if (stepInfo.width < 1 || stepInfo.height < 1) { 15 | // don't permit <= 0 inputs 16 | throw new Error('crop width or height cannot be <= 0'); 17 | } 18 | 19 | if (isNaN(stepInfo.width)) { 20 | stepInfo.width = img.info.width - (stepInfo.left || 0); 21 | } 22 | 23 | if (isNaN(stepInfo.height)) { 24 | stepInfo.height = img.info.height - (stepInfo.top || 0); 25 | } 26 | 27 | if (isNaN(stepInfo.top)) { 28 | stepInfo.top = Math.round(anchorY - stepInfo.height / 2); 29 | } 30 | 31 | if (isNaN(stepInfo.left)) { 32 | stepInfo.left = Math.round(anchorX - stepInfo.width / 2); 33 | } 34 | 35 | if (stepInfo.left < 0) { 36 | stepInfo.left = 0; 37 | } 38 | if (stepInfo.left + stepInfo.width > img.info.width) { 39 | stepInfo.left = Math.max(0, img.info.width - stepInfo.width); 40 | } 41 | if (stepInfo.left + stepInfo.width > img.info.width) { 42 | // cap width 43 | stepInfo.width = img.info.width - stepInfo.left; 44 | } 45 | 46 | if (stepInfo.top < 0) { 47 | stepInfo.top = 0; 48 | } 49 | if (stepInfo.top + stepInfo.height > img.info.height) { 50 | stepInfo.top = Math.max(0, img.info.height - stepInfo.height); 51 | } 52 | if (stepInfo.top + stepInfo.height > img.info.height) { 53 | // cap height 54 | stepInfo.height = img.info.height - stepInfo.top; 55 | } 56 | 57 | context.sharp.extract(_.pick(stepInfo, ['top', 'left', 'width', 'height'])); 58 | 59 | // track new dimensions for followup operations 60 | img.info.width = stepInfo.width; 61 | img.info.height = stepInfo.height; 62 | }; 63 | -------------------------------------------------------------------------------- /lib/processor/steps/extend.js: -------------------------------------------------------------------------------- 1 | var helpers = require('../../helpers'); 2 | 3 | module.exports = function (context, stepInfo) { 4 | var img = context.processedImage; 5 | helpers.dimension.resolveStep(img, stepInfo); 6 | 7 | if (isNaN(stepInfo.top)) { 8 | stepInfo.top = 0; 9 | } else { 10 | stepInfo.top = parseInt(stepInfo.top); 11 | } 12 | 13 | if (isNaN(stepInfo.bottom)) { 14 | stepInfo.bottom = 0; 15 | } else { 16 | stepInfo.bottom = parseInt(stepInfo.bottom); 17 | } 18 | 19 | if (isNaN(stepInfo.left)) { 20 | stepInfo.left = 0; 21 | } else { 22 | stepInfo.left = parseInt(stepInfo.left); 23 | } 24 | 25 | if (isNaN(stepInfo.right)) { 26 | stepInfo.right = 0; 27 | } else { 28 | stepInfo.right = parseInt(stepInfo.right); 29 | } 30 | 31 | var rgba = helpers.rgba.getRGBA(stepInfo); 32 | 33 | context.sharp.extend({ 34 | top: stepInfo.top, 35 | left: stepInfo.left, 36 | bottom: stepInfo.bottom, 37 | right: stepInfo.right, 38 | background: rgba, 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /lib/processor/steps/flatten.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | context.sharp.flatten(); 3 | }; 4 | -------------------------------------------------------------------------------- /lib/processor/steps/flip.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | if (stepInfo.x !== undefined) { 3 | context.sharp.flop(); 4 | } 5 | 6 | if (stepInfo.y !== undefined) { 7 | context.sharp.flip(); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /lib/processor/steps/format.js: -------------------------------------------------------------------------------- 1 | var permittedFormats = { 2 | jpeg: true, 3 | png: true, 4 | webp: true, 5 | raw: true, 6 | avif: true, 7 | gif: true, 8 | }; 9 | 10 | module.exports = function (context, stepInfo) { 11 | var fmt = stepInfo.format in permittedFormats ? stepInfo.format : 'jpeg'; 12 | if (fmt === 'webp' && /^win/.test(process.platform)) { 13 | // webp currently unsupported on windows 14 | fmt = 'jpeg'; 15 | } 16 | 17 | context.processedImage.info.format = fmt; 18 | 19 | context.sharp.toFormat(fmt, context.formatOptions); 20 | }; 21 | -------------------------------------------------------------------------------- /lib/processor/steps/gamma.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | context.sharp.gamma(parseFloat(stepInfo.gamma || '2.2')); 3 | }; 4 | -------------------------------------------------------------------------------- /lib/processor/steps/greyscale.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | context.sharp.greyscale(); 3 | }; 4 | -------------------------------------------------------------------------------- /lib/processor/steps/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | blur: require('./blur'), 3 | compression: require('./compression'), 4 | crop: require('./crop'), 5 | extend: require('./extend'), 6 | flatten: require('./flatten'), 7 | flip: require('./flip'), 8 | format: require('./format'), 9 | gamma: require('./gamma'), 10 | greyscale: require('./greyscale'), 11 | lossless: require('./lossless'), 12 | metadata: require('./metadata'), 13 | normalize: require('./normalize'), 14 | progressive: require('./progressive'), 15 | quality: require('./quality'), 16 | resize: require('./resize'), 17 | rotate: require('./rotate'), 18 | sharpen: require('./sharpen'), 19 | }; 20 | -------------------------------------------------------------------------------- /lib/processor/steps/lossless.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | if (stepInfo.near === 'true') { 3 | // one or the other 4 | context.formatOptions.nearLossless = true; 5 | } else { 6 | context.formatOptions.lossless = true; 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /lib/processor/steps/metadata.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | if (stepInfo.enabled !== 'true') { 3 | return; 4 | } 5 | 6 | // defaults to pulling over meta data 7 | 8 | context.sharp.withMetadata(); 9 | }; 10 | -------------------------------------------------------------------------------- /lib/processor/steps/normalize.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | context.sharp.normalize(); 3 | }; 4 | -------------------------------------------------------------------------------- /lib/processor/steps/progressive.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | context.formatOptions.progressive = true; 3 | }; 4 | -------------------------------------------------------------------------------- /lib/processor/steps/quality.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | context.formatOptions.quality = 3 | (stepInfo.quality && parseInt(stepInfo.quality)) || 80; 4 | }; 5 | -------------------------------------------------------------------------------- /lib/processor/steps/resize.js: -------------------------------------------------------------------------------- 1 | var helpers = require('../../helpers'); 2 | 3 | module.exports = function (context, stepInfo) { 4 | var img = context.processedImage; 5 | helpers.dimension.resolveStep(img, stepInfo); 6 | 7 | if (stepInfo.width < 1 || stepInfo.height < 1) { 8 | // don't permit <= 0 inputs 9 | throw new Error('resize width or height cannot be <= 0'); 10 | } 11 | 12 | if (isNaN(stepInfo.width) && isNaN(stepInfo.height)) { 13 | throw new Error('resize width or height required'); 14 | } 15 | 16 | var aspectX = helpers.dimension.getXAspect(img); 17 | var aspectY = helpers.dimension.getYAspect(img); 18 | 19 | if (isNaN(stepInfo.width)) { 20 | stepInfo.width = Math.round(stepInfo.height * aspectX); 21 | } 22 | 23 | if (isNaN(stepInfo.height)) { 24 | stepInfo.height = Math.round(stepInfo.width * aspectY); 25 | } 26 | 27 | if (context.options.maxSize) { 28 | // request should never exceed permitted output size 29 | if (stepInfo.width > context.options.maxSize.width) { 30 | stepInfo.width = context.options.maxSize.width; 31 | } 32 | if (stepInfo.height > context.options.maxSize.height) { 33 | stepInfo.height = context.options.maxSize.height; 34 | } 35 | } 36 | 37 | var ignoreAspect = stepInfo.ignoreAspect === 'true'; 38 | 39 | if (!ignoreAspect) { 40 | var w = stepInfo.width, 41 | h = stepInfo.height; 42 | 43 | if (stepInfo.min !== undefined) { 44 | // use min if specified 45 | 46 | // apply aspect 47 | h = Math.round(stepInfo.width * aspectY); 48 | 49 | if (h < stepInfo.height) { 50 | // if height less than minimum, set to min 51 | h = stepInfo.height; 52 | w = Math.ceil(stepInfo.height * aspectX); 53 | } 54 | 55 | if (w < stepInfo.width) { 56 | // if width less than minimum, set to min 57 | w = stepInfo.width; 58 | h = Math.ceil(stepInfo.width * aspectY); 59 | } 60 | } else { 61 | // use max otherwise 62 | 63 | // apply aspect 64 | h = Math.round(w * aspectY); 65 | 66 | if (h > stepInfo.height) { 67 | // if height more than maximum, reduce to max 68 | h = stepInfo.height; 69 | w = Math.floor(h * aspectX); 70 | } 71 | 72 | if (w > stepInfo.width) { 73 | // if width more than maximum, reduce to max 74 | w = stepInfo.width; 75 | h = Math.floor(w * aspectY); 76 | } 77 | } 78 | 79 | stepInfo.width = w; 80 | stepInfo.height = h; 81 | } 82 | 83 | if (stepInfo.canGrow !== 'true') { 84 | if (stepInfo.width > img.info.width) { 85 | stepInfo.width = img.info.width; 86 | if (!ignoreAspect) { 87 | stepInfo.height = Math.ceil(stepInfo.width * aspectY); 88 | } 89 | } 90 | 91 | if (stepInfo.height > img.info.height) { 92 | stepInfo.height = img.info.height; 93 | if (!ignoreAspect) { 94 | stepInfo.width = Math.ceil(stepInfo.height * aspectX); 95 | } 96 | } 97 | } 98 | 99 | // track new dimensions for followup operations 100 | img.info.width = stepInfo.width; 101 | img.info.height = stepInfo.height; 102 | 103 | var rgba = helpers.rgba.getRGBA(stepInfo); 104 | context.sharp.resize(stepInfo.width, stepInfo.height, { 105 | interpolator: stepInfo.interpolator || 'bicubic', 106 | fit: stepInfo.fit || 'fill', // we'll handle aspect ourselves (by default) to avoid having to recompute dimensions 107 | position: stepInfo.position || 'centre', 108 | background: rgba, 109 | }); 110 | }; 111 | -------------------------------------------------------------------------------- /lib/processor/steps/rotate.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | var img = context.processedImage; 3 | var w = img.info.width; 4 | var h = img.info.height; 5 | var degrees = parseInt(stepInfo.degrees) || 0; // account for 'auto' 6 | 7 | if (img.info.orientation) { 8 | // https://sirv.com/help/articles/rotate-photos-to-be-upright 9 | switch (img.info.orientation) { 10 | case 2: // UpMirrored 11 | context.sharp.flop(); // x 12 | degrees = 360 - degrees; // invert 13 | break; 14 | case 3: // Down 15 | degrees = (degrees + 180) % 360; 16 | break; 17 | case 4: // DownMirrored 18 | context.sharp.flop(); // x 19 | degrees = (degrees + 180) % 360; 20 | degrees = 360 - degrees; // invert 21 | break; 22 | case 5: // LeftMirrored 23 | context.sharp.flip(); // y 24 | degrees = (degrees + 90) % 360; 25 | if (degrees === 180) degrees = 0; 26 | else if (degrees === 0) degrees = 180; 27 | break; 28 | case 6: // Left 29 | degrees = (degrees + 90) % 360; 30 | break; 31 | case 7: // RightMirrored 32 | context.sharp.flip(); // y 33 | degrees = (degrees + 270) % 360; 34 | if (degrees === 180) degrees = 0; 35 | else if (degrees === 0) degrees = 180; 36 | break; 37 | case 8: // Right 38 | degrees = (degrees + 270) % 360; 39 | break; 40 | // otherwise do nothing 41 | } 42 | 43 | // remove orientation now that it's been auto-corrected 44 | // to avoid downloaded asset from being rotated again 45 | // delete img.info.orientation; 46 | } 47 | 48 | switch (degrees) { 49 | case 90: 50 | stepInfo.degrees = 90; 51 | // invert dimensions 52 | img.info.width = h; 53 | img.info.height = w; 54 | break; 55 | case 180: 56 | stepInfo.degrees = 180; 57 | break; 58 | case 270: 59 | stepInfo.degrees = 270; 60 | // invert dimensions 61 | img.info.width = h; 62 | img.info.height = w; 63 | break; 64 | default: 65 | // 0 or invalid 66 | return; // do nothing 67 | } 68 | 69 | context.sharp.rotate(stepInfo.degrees); 70 | }; 71 | -------------------------------------------------------------------------------- /lib/processor/steps/sharpen.js: -------------------------------------------------------------------------------- 1 | module.exports = function (context, stepInfo) { 2 | var radius, flat, jagged; 3 | 4 | if (stepInfo.radius) { 5 | radius = parseFloat(stepInfo.radius); 6 | } 7 | 8 | if (stepInfo.flat) { 9 | flat = parseFloat(stepInfo.flat); 10 | } 11 | 12 | if (stepInfo.jagged) { 13 | jagged = parseFloat(stepInfo.jagged); 14 | } 15 | 16 | context.sharp.sharpen(radius, flat, jagged); 17 | }; 18 | -------------------------------------------------------------------------------- /lib/router/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./router'); 2 | -------------------------------------------------------------------------------- /lib/router/router-defaults.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pathDelimiter: '/:/', 3 | signatureDelimiter: '/-/', 4 | cmdKeyDelimiter: '/', 5 | cmdValDelimiter: '=', 6 | paramKeyDelimiter: ',', 7 | paramValDelimiter: ':', 8 | supportWebP: true, 9 | supportAVIF: false, // disabled by default due to very expensive encoder 10 | originalSteps: { 11 | resize: { width: '2560', height: '2560', max: 'true', canGrow: 'false' }, 12 | quality: { quality: '95' }, 13 | metadata: { enabled: 'true' }, 14 | format: { format: 'webp' }, 15 | }, 16 | hqOriginalMaxPixels: 400 * 400, // < 300KB lossless compression 17 | hqOriginalSteps: { 18 | lossless: { near: 'true' }, 19 | metadata: { enabled: 'true' }, 20 | format: { format: 'webp' }, 21 | }, 22 | commands: { 23 | $colors: { 24 | name: 'colors', 25 | w: 'width', 26 | h: 'height', 27 | mc: 'maxColors', 28 | cc: 'cubicCells', 29 | mn: 'mean', 30 | o: 'order', 31 | }, 32 | $info: { 33 | name: 'info', 34 | }, 35 | }, 36 | steps: { 37 | rs: { 38 | name: 'resize', 39 | w: 'width', 40 | h: 'height', 41 | l: 'left', 42 | t: 'top', 43 | m: 'min', 44 | mx: 'max', 45 | cg: 'canGrow', 46 | i: 'ignoreAspect', 47 | int: 'interpolator', 48 | bg: 'background', 49 | ft: 'fit', 50 | ps: 'position', 51 | }, 52 | exd: { 53 | name: 'extend', 54 | t: 'top', 55 | b: 'bottom', 56 | l: 'left', 57 | r: 'right', 58 | bg: 'background', 59 | }, 60 | cr: { 61 | name: 'crop', 62 | t: 'top', 63 | l: 'left', 64 | w: 'width', 65 | h: 'height', 66 | a: 'anchor', 67 | ax: 'anchorX', 68 | ay: 'anchorY', 69 | }, 70 | fm: { 71 | name: 'format', 72 | f: 'format', 73 | }, 74 | qt: { 75 | name: 'quality', 76 | q: 'quality', 77 | }, 78 | cp: { 79 | name: 'compression', 80 | c: 'compression', 81 | }, 82 | pg: { 83 | name: 'progressive', 84 | }, 85 | rt: { 86 | name: 'rotate', 87 | d: 'degrees', 88 | }, 89 | fl: { 90 | name: 'flip', 91 | x: 'x', 92 | y: 'y', 93 | }, 94 | md: { 95 | name: 'metadata', 96 | e: 'enabled', 97 | }, 98 | ft: { 99 | name: 'flatten', 100 | }, 101 | ip: { 102 | name: 'interpolation', 103 | i: 'interpolator', 104 | }, 105 | gm: { 106 | name: 'gamma', 107 | g: 'gamma', 108 | }, 109 | ll: { 110 | name: 'lossless', 111 | n: 'near', 112 | }, 113 | 'fx-gs': { 114 | name: 'greyscale', 115 | }, 116 | 'fx-sp': { 117 | name: 'sharpen', 118 | r: 'radius', 119 | f: 'flat', 120 | j: 'jagged', 121 | }, 122 | 'fx-nm': { 123 | name: 'normalize', 124 | }, 125 | 'fx-bl': { 126 | name: 'blur', 127 | s: 'sigma', 128 | }, 129 | }, 130 | }; 131 | -------------------------------------------------------------------------------- /lib/router/router.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | var _ = require('lodash'); 3 | var EventEmitter = require('events').EventEmitter; 4 | var helpers = require('../helpers'); 5 | var defaults = require('./router-defaults'); 6 | var mime = require('mime'); 7 | 8 | module.exports = Router; 9 | 10 | function Router(options) { 11 | if (!(this instanceof Router)) { 12 | return new Router(options); 13 | } 14 | 15 | EventEmitter.call(this); 16 | 17 | this.allOptions = options; 18 | this.appOptions = 19 | (options.storage && options.storage.app && options.storage.app) || {}; 20 | this.options = _.merge({}, defaults, options.router || {}); 21 | if (this.options.canDisableCache === undefined) { 22 | this.canDisableCache = process.env.NODE_ENV !== 'production'; 23 | } else { 24 | this.canDisableCache = this.options.canDisableCache; 25 | } 26 | 27 | if (/^win/.test(process.platform)) { 28 | // force disable WebP on Windows 29 | this.options.supportWebP = false; 30 | } 31 | } 32 | 33 | var p = (Router.prototype = new EventEmitter()); 34 | 35 | /* FORMAT 36 | {path}{pathDelimiter}{cmd1}{cmdValDelimiter}{cmd1Param1Key}{paramValDelimiter}{cmd1Param1Value}{paramKeyDelimiter}{cmdKeyDelimiter}{signatureDelimiter}{signature}?{queryString} 37 | */ 38 | 39 | p.getInfo = function (req, opts) { 40 | const urlInfo = url.parse(req.url, true); 41 | const appName = urlInfo.pathname.split('/')[1]; 42 | const appOptions = this.appOptions[appName] || {}; 43 | const routerOptions = appOptions.router; 44 | const options = _.merge({}, this.options, routerOptions || {}); 45 | 46 | let originalSteps, hashFromOptimizedOriginal, hqOriginalSteps; 47 | if (typeof options.originalSteps === 'object') { 48 | originalSteps = this.getStepsFromObject(options.originalSteps, options); 49 | hashFromOptimizedOriginal = 50 | helpers.imageSteps.getHashFromSteps(originalSteps); 51 | } 52 | 53 | if (typeof options.hqOriginalSteps === 'object') { 54 | hqOriginalSteps = this.getStepsFromObject(options.hqOriginalSteps, options); 55 | } 56 | 57 | var routeInfo = { 58 | urlInfo: urlInfo, 59 | originalSteps: originalSteps, 60 | hashFromOptimizedOriginal: hashFromOptimizedOriginal, 61 | hqOriginalSteps: hqOriginalSteps, 62 | }; 63 | 64 | var signatureParts = routeInfo.urlInfo.pathname.split( 65 | options.signatureDelimiter 66 | ); 67 | routeInfo.toSign = signatureParts[0]; 68 | routeInfo.signature = signatureParts[1]; 69 | 70 | // encoding does not belong here -- rely on storage providers to encode as necessary 71 | routeInfo.urlInfo.pathname = decodeURI(routeInfo.urlInfo.pathname); 72 | routeInfo.isCachable = 73 | !this.canDisableCache || // cannot disable cache 74 | routeInfo.urlInfo.query.cache !== 'false'; // or request isn't disabling cache 75 | 76 | routeInfo.optimized = routeInfo.urlInfo.query.optimized === 'true'; 77 | 78 | // Append search query param to pathname if pathDelimiter = '?' 79 | if (options.pathDelimiter === '?' && routeInfo.urlInfo.search) { 80 | routeInfo.urlInfo.pathname = `${routeInfo.urlInfo.pathname}${routeInfo.urlInfo.search}`; 81 | } 82 | 83 | // Run the preprocessor if provided in the configuration. 84 | if (typeof options.beforeProcess === 'function') { 85 | options.beforeProcess(routeInfo, options); 86 | } 87 | 88 | // break apart imagePath from imageSteps from queryParams 89 | var pathParts = routeInfo.urlInfo.pathname 90 | .split(options.signatureDelimiter)[0] 91 | .split(options.pathDelimiter); 92 | routeInfo.originalPath = pathParts[0].substr(1); // remove `/` prefix from path 93 | 94 | routeInfo.imageSteps = getImageStepsFromRoute.call(this, pathParts[1]); 95 | if (routeInfo.imageSteps.length === 0) { 96 | // attempt to determine content type only if no image steps are provided 97 | routeInfo.contentType = getContentType(req, routeInfo); 98 | if (routeInfo.contentType) { 99 | // we've determined this is not an image, use as-is 100 | return routeInfo; 101 | } 102 | } 103 | 104 | if (routeInfo.imageSteps.length > 0 && routeInfo.imageSteps[0].command) { 105 | // not an image step, but instead a command 106 | routeInfo.command = routeInfo.imageSteps[0]; 107 | routeInfo.imageSteps = []; // reset 108 | return routeInfo; 109 | } 110 | 111 | const useOriginal = 112 | options.useOriginal || routeInfo.urlInfo.query.useOriginal === 'true'; 113 | 114 | // forward original image if no operation on image and useOriginal query or option 115 | if (routeInfo.imageSteps.length === 0 && useOriginal) { 116 | routeInfo.useOriginal = true; 117 | return routeInfo; 118 | } 119 | 120 | routeInfo.flatSteps = flattenSteps(routeInfo.imageSteps); 121 | 122 | if (!routeInfo.flatSteps.format) { 123 | // if WebP is not enabled or supported by browser, use appropriate lossless or lossy fallback format 124 | const fallbackFormat = routeInfo.flatSteps.lossless ? 'png' : 'jpeg'; 125 | if ( 126 | routeInfo.urlInfo.query.download !== undefined || 127 | routeInfo.flatSteps.progressive 128 | ) { 129 | // force fallback if downloading or progressive 130 | routeInfo.flatSteps.format = { name: 'format', format: fallbackFormat }; 131 | } else { 132 | // use user agent optimized format if format not already provided in request 133 | const useAVIF = 134 | options.supportAVIF && 135 | req.headers.accept && 136 | /image\/avif/.test(req.headers.accept); 137 | const useWebP = 138 | options.supportWebP && 139 | req.headers.accept && 140 | /image\/webp/.test(req.headers.accept) && 141 | !/^win/.test(process.platform); 142 | var fmt = useAVIF ? 'avif' : useWebP ? 'webp' : fallbackFormat; 143 | 144 | routeInfo.flatSteps.format = { name: 'format', format: fmt }; 145 | } 146 | routeInfo.imageSteps.push(routeInfo.flatSteps.format); 147 | } 148 | 149 | if (!routeInfo.flatSteps.metadata) { 150 | // always use metadata step if one is not provided 151 | routeInfo.flatSteps.metadata = { name: 'metadata', enabled: 'true' }; 152 | routeInfo.imageSteps.push(routeInfo.flatSteps.metadata); 153 | } 154 | 155 | if (!routeInfo.flatSteps.rotate) { 156 | // enforce auto-rotation to account for orientation 157 | // adding this here also auto-corrects existing images by bypassing cache 158 | // prepend to beginning of steps to avoid changes in aspect from impacting rest of operations 159 | routeInfo.flatSteps.rotate = { name: 'rotate', degrees: 'auto' }; 160 | routeInfo.imageSteps.splice(0, 0, routeInfo.flatSteps.rotate); 161 | } 162 | 163 | // backward compatibility to merge steps due to breaking change in sharp 164 | // DEPRECATION NOTICE!!! 165 | if (routeInfo.flatSteps.interpolation && routeInfo.flatSteps.resize) { 166 | routeInfo.flatSteps.resize.interpolator = 167 | routeInfo.flatSteps.interpolation.interpolator; 168 | } 169 | 170 | // if useOriginal is supplied, modify the hash to avoid collision with non-original with same steps 171 | const imageSteps = useOriginal 172 | ? [...routeInfo.imageSteps, { useOriginal }] 173 | : routeInfo.imageSteps; 174 | 175 | routeInfo.hashFromSteps = helpers.imageSteps.getHashFromSteps(imageSteps); 176 | 177 | return routeInfo; 178 | }; 179 | 180 | p.getStepsFromObject = function (obj, options) { 181 | var steps = []; 182 | 183 | Object.keys(obj).forEach((key) => { 184 | if (!obj.hasOwnProperty(key)) { 185 | return; 186 | } 187 | var val = _.merge({ name: key }, obj[key]); 188 | 189 | if (key === 'format' && val.format === 'webp' && !options.supportWebP) { 190 | val.format = 'jpeg'; 191 | } 192 | 193 | steps.push(val); 194 | }); 195 | 196 | return steps; 197 | }; 198 | 199 | function getContentType(req, routeInfo) { 200 | var contentType = mime.getType(routeInfo.originalPath); 201 | if ( 202 | (!/image/i.test(contentType) || /image\/svg/i.test(contentType)) && 203 | contentType !== 'application/octet-stream' 204 | ) { 205 | // if non-image, return known content type 206 | return contentType; 207 | } 208 | 209 | // content type unknown, assume image 210 | // return undefined 211 | } 212 | 213 | function getImageStepsFromRoute(imageStepsStr) { 214 | if (!imageStepsStr) return []; 215 | var imageSteps = imageStepsStr.split(this.options.cmdKeyDelimiter); 216 | 217 | var $this = this; 218 | return imageSteps.map(function (stepStr) { 219 | // format: crop=t:15,l:10,w:-10,h:-15 220 | const stepParts = stepStr.split($this.options.cmdValDelimiter); 221 | const shortName = stepParts[0]; 222 | 223 | const cmdConfig = $this.options.commands[shortName]; 224 | const stepConfig = cmdConfig ? null : $this.options.steps[shortName]; 225 | const config = cmdConfig || stepConfig; 226 | if (!config) { 227 | throw new Error('Unsupported step: ' + stepStr); 228 | } 229 | 230 | const step = { 231 | command: cmdConfig ? true : false, 232 | name: config.name, // use full name from config 233 | }; 234 | 235 | if (stepParts.length < 2) { 236 | return step; 237 | } 238 | 239 | var stepParams = stepParts[1].split($this.options.paramKeyDelimiter); 240 | stepParams.forEach(function (stepParam) { 241 | var paramParts = stepParam.split($this.options.paramValDelimiter); 242 | var paramName = paramParts[0]; 243 | var fullParamName = config[paramName]; 244 | if (!fullParamName) { 245 | throw new Error( 246 | 'Unsupported param ' + paramName + ' in step ' + stepStr 247 | ); 248 | } 249 | if (paramParts.length >= 2) { 250 | step[fullParamName] = paramParts[1]; 251 | } else { 252 | // use a truthy value if key exists with no value 253 | step[fullParamName] = true; 254 | } 255 | }); 256 | 257 | return step; 258 | }); 259 | } 260 | 261 | function flattenSteps(steps) { 262 | return steps.reduce((ctx, s) => { 263 | ctx[s.name] = s; 264 | return ctx; 265 | }, {}); 266 | } 267 | -------------------------------------------------------------------------------- /lib/security/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./security'); 2 | -------------------------------------------------------------------------------- /lib/security/security-defaults.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | enabled: false, 3 | algorithm: 'sha1', 4 | }; 5 | -------------------------------------------------------------------------------- /lib/security/security.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto'); 2 | var _ = require('lodash'); 3 | var EventEmitter = require('events').EventEmitter; 4 | var defaults = require('./security-defaults'); 5 | 6 | module.exports = Security; 7 | 8 | function Security(options) { 9 | if (!(this instanceof Security)) { 10 | return new Security(options); 11 | } 12 | 13 | EventEmitter.call(this); 14 | this.options = _.merge({}, defaults, options || {}); 15 | 16 | if (this.options.enabled && !this.options.secret) { 17 | throw new SecurityError('You must set a secret to enable Security'); 18 | } 19 | } 20 | 21 | var p = (Security.prototype = new EventEmitter()); 22 | 23 | p.checkSignature = function (toSign, signature) { 24 | var $this = this; 25 | 26 | if (!$this.options.enabled) { 27 | return; 28 | } 29 | 30 | if (!signature || typeof signature !== 'string') { 31 | throw new SecurityError( 32 | 'This resource is protected, please use a signed url' 33 | ); 34 | } 35 | 36 | var shasum = crypto.createHash($this.options.algorithm); 37 | shasum.update(toSign + $this.options.secret); 38 | var expectedSignature = shasum 39 | .digest('base64') 40 | .replace(/\//g, '_') 41 | .replace(/\+/g, '-') 42 | .substring(0, 8); 43 | 44 | if (signature !== expectedSignature) { 45 | throw new SecurityError('Signature does not match'); 46 | } 47 | }; 48 | 49 | p.SecurityError = SecurityError; 50 | 51 | function SecurityError(message) { 52 | this.message = message; 53 | } 54 | 55 | SecurityError.prototype = Object.create(Error.prototype); 56 | SecurityError.prototype.name = 'SecurityError'; 57 | SecurityError.prototype.message = ''; 58 | SecurityError.prototype.constructor = SecurityError; 59 | -------------------------------------------------------------------------------- /lib/storage/fs/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs-extra'); 3 | const _ = require('lodash'); 4 | const StorageBase = require('../storage-base'); 5 | 6 | module.exports = StorageFs; 7 | 8 | function StorageFs(options) { 9 | StorageBase.apply(this, arguments); 10 | } 11 | 12 | const p = (StorageFs.prototype = new StorageBase()); 13 | 14 | p.fetch = function (options, originalPath, stepsHash, cb) { 15 | const filename = path.resolve( 16 | options.path || './', 17 | stepsHash ? `${originalPath}-${stepsHash}` : originalPath 18 | ); 19 | 20 | fs.readFile(filename + '.json', 'utf8', function (err, data) { 21 | let info = { path: originalPath, stepsHash: stepsHash }; 22 | if (data) { 23 | try { 24 | info = _.merge(info, JSON.parse(data.toString())); 25 | } catch (err) { 26 | return cb(err); 27 | } 28 | } 29 | 30 | let error, fileStats, fileData; 31 | 32 | fs.stat(filename, (err, stats) => { 33 | if (err) { 34 | if (error) return; // cb already called 35 | 36 | if (err.code === 'ENOENT') { 37 | err.statusCode = 404; 38 | } 39 | 40 | error = err; 41 | return void cb(err); 42 | } 43 | 44 | fileStats = stats; 45 | info.lastModified = stats.mtime; 46 | if (fileStats && fileData) { 47 | cb(null, info, fileData); 48 | } 49 | }); 50 | 51 | fs.readFile(filename, (err, data) => { 52 | if (err) { 53 | if (error) return; // cb already called 54 | if (err.code === 'ENOENT') err.statusCode = 404; 55 | error = err; 56 | return void cb(err); 57 | } 58 | 59 | fileData = data; 60 | if (fileStats && fileData) { 61 | cb(null, info, fileData); 62 | } 63 | }); 64 | }); 65 | }; 66 | 67 | function checkDir(filename, cb) { 68 | const folder = path.dirname(filename); 69 | fs.stat(folder, (err, data) => { 70 | if (!err) return cb(); 71 | if (err.code === 'ENOENT') { 72 | fs.mkdirp(folder, (err) => { 73 | if (err) { 74 | return void cb(err); 75 | } 76 | cb(); 77 | }); 78 | } else { 79 | return void cb(err); 80 | } 81 | }); 82 | } 83 | 84 | p.touch = function (options, originalPath, stepsHash, image, cb) { 85 | const filename = path.resolve( 86 | options.path || './', 87 | `${originalPath}-${stepsHash}` 88 | ); 89 | const now = new Date(); 90 | 91 | // touch 92 | fs.utimes(filename, now, now, cb); 93 | }; 94 | 95 | p.store = function (options, originalPath, stepsHash, image, cb) { 96 | const filename = path.resolve( 97 | options.path || './', 98 | `${originalPath}-${stepsHash}` 99 | ); 100 | 101 | image.info.stepsHash = stepsHash; 102 | 103 | checkDir(filename, (err) => { 104 | if (err) { 105 | return void cb(err); 106 | } 107 | fs.writeFile( 108 | filename + '.json', 109 | Buffer.from(JSON.stringify(image.info)), 110 | 'utf8', 111 | function (err) { 112 | // do nothing 113 | } 114 | ); 115 | fs.writeFile(filename, image.buffer, cb); 116 | }); 117 | }; 118 | 119 | p.deleteCache = function (options, originalPath, cb) { 120 | const cachePath = path.resolve(options.path || './'); 121 | 122 | fs.remove(cachePath, cb); 123 | }; 124 | -------------------------------------------------------------------------------- /lib/storage/http/index.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var fs = require('fs'); 3 | var _ = require('lodash'); 4 | var http = require('http'); 5 | var https = require('https'); 6 | var URL = require('url'); 7 | var StorageBase = require('../storage-base'); 8 | 9 | module.exports = StorageHttp; 10 | 11 | function StorageHttp(options) { 12 | StorageBase.apply(this, arguments); 13 | } 14 | 15 | var p = (StorageHttp.prototype = new StorageBase()); 16 | 17 | p.fetch = function (options, originalPath, stepsHash, cb) { 18 | var pathInfo = this.getPathInfo(originalPath, options); 19 | if (!pathInfo) { 20 | return void cb(new Error('Invalid path')); 21 | } 22 | 23 | var client = this.getClient(options); 24 | var reqOptions = this.getRequestOptions(pathInfo, options); 25 | if (stepsHash) reqOptions.path += '/' + stepsHash; 26 | 27 | var bufs = []; 28 | 29 | client 30 | .request(reqOptions, function (res) { 31 | if (res.statusCode !== 200) { 32 | const err = new Error( 33 | 'storage.http.fetch.error: ' + 34 | res.statusCode + 35 | ' for ' + 36 | pathInfo.bucket + 37 | '/' + 38 | pathInfo.imagePath 39 | ); 40 | err.statusCode = res.statusCode; 41 | return void cb(err); 42 | } 43 | 44 | res.on('data', function (chunk) { 45 | bufs.push(chunk); 46 | }); 47 | 48 | res.on('end', function () { 49 | var info = { path: originalPath }; 50 | var meta = {}; 51 | try { 52 | meta = JSON.parse( 53 | res.headers['x-isteam-meta'] || 54 | res.headers['x-amz-meta-isteam'] || 55 | '{}' 56 | ); 57 | } catch (ex) { 58 | // eat it and use defaults 59 | } 60 | var info = _.merge( 61 | { path: encodeURIComponent(originalPath), stepsHash: stepsHash }, 62 | meta // merge in object meta 63 | ); 64 | cb(null, info, Buffer.concat(bufs)); 65 | }); 66 | }) 67 | .on('error', function (err) { 68 | cb(err); 69 | }) 70 | .end(); 71 | }; 72 | 73 | p.store = function () { 74 | throw new Error( 75 | 'Http Storage driver is read-only. Use cache or other driver for writing' 76 | ); 77 | }; 78 | 79 | p.getClient = function (options) { 80 | return this.isSecure(options) ? https : http; 81 | }; 82 | 83 | p.getPathInfo = function (filePath, options) { 84 | var firstSlash = filePath.indexOf('/'); 85 | var isBucketInPath = options.bucket === undefined; 86 | 87 | return { 88 | bucket: isBucketInPath ? filePath.substr(0, firstSlash) : options.bucket, 89 | imagePath: filePath.substr(isBucketInPath ? firstSlash + 1 : 0), 90 | }; 91 | }; 92 | 93 | p.isSecure = function (options) { 94 | return /^https\:/i.test(options.endpoint); 95 | }; 96 | 97 | p.getRequestOptions = function (pathInfo, options) { 98 | const headers = { ...(options.headers || {}) }; // default 99 | const urlInfo = URL.parse(options.endpoint); 100 | 101 | const trackReferer = options['x-track-origin-referer']; 102 | if (trackReferer) { 103 | headers['x-track-origin-referer'] = trackReferer; 104 | } 105 | 106 | const sep = urlInfo.path[urlInfo.path.length - 1] === '/' ? '' : '/'; 107 | const bucketPath = pathInfo.bucket ? `${pathInfo.bucket}/` : ''; 108 | const postfix = options.isteamEndpoint 109 | ? options.useOriginal 110 | ? '?useOriginal=true' 111 | : '?optimized=true' 112 | : ''; 113 | return { 114 | protocol: urlInfo.protocol, 115 | hostname: urlInfo.hostname, 116 | port: urlInfo.port 117 | ? parseInt(urlInfo.port) 118 | : urlInfo.protocol === 'https:' 119 | ? 443 120 | : 80, 121 | path: `${urlInfo.path}${sep}${bucketPath}${encodeURI( 122 | pathInfo.imagePath 123 | )}${postfix}`, 124 | method: 'GET', 125 | headers, 126 | }; 127 | }; 128 | -------------------------------------------------------------------------------- /lib/storage/index.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var _ = require('lodash'); 3 | var EventEmitter = require('events').EventEmitter; 4 | var Image = require('../image'); 5 | var StorageBase = require('./storage-base'); 6 | 7 | module.exports = Storage; 8 | 9 | function Storage(options) { 10 | if (!(this instanceof Storage)) { 11 | return new Storage(options); 12 | } 13 | 14 | EventEmitter.call(this); 15 | 16 | this.options = options || {}; 17 | 18 | this.artifactReplicas = []; 19 | this.optimizedReplicas = []; 20 | if (this.options.replicas) { 21 | Object.keys(this.options.replicas).forEach((key) => { 22 | const replica = this.options.replicas[key]; 23 | if (!replica) return; 24 | if (replica.replicateArtifacts !== false && replica.cache) { 25 | this.artifactReplicas.push(replica.cache); 26 | } 27 | const cache = replica.cacheOptimized || replica.cache; 28 | if (cache) { 29 | this.optimizedReplicas.push(cache); 30 | } 31 | }); 32 | } 33 | 34 | // drivers will be initialized on-demand due to the light weight nature of design 35 | this.drivers = {}; 36 | } 37 | 38 | Storage.Base = StorageBase; 39 | 40 | var p = (Storage.prototype = new EventEmitter()); 41 | 42 | p.getDriver = function (options, prefix) { 43 | const name = options.driverPath 44 | ? `${prefix}:byPath/${options.driverPath}` 45 | : `${prefix}:byName/${options.driver}`; 46 | let driver = this.drivers[name]; 47 | 48 | function createDriver(opts) { 49 | if (opts.driverPath) { 50 | return new (require(opts.driverPath))(opts); 51 | } else if (opts.driver) { 52 | return new (require('./' + opts.driver))(opts); 53 | } else { 54 | throw new Error('No driver provided'); 55 | } 56 | } 57 | 58 | if (!driver) { 59 | // if not found, create it 60 | driver = createDriver(options); 61 | driver.name = name; 62 | 63 | // store by name 64 | this.drivers[driver.name] = driver; 65 | } 66 | 67 | return driver; 68 | }; 69 | 70 | p.fetch = function (req, reqOptions, fetchOptions, cb) { 71 | cb = _.once(cb); // account for flaky error handling within storage clients to avoid internal failures 72 | 73 | const { originalPath, hashFromOptimizedOriginal, urlInfo } = reqOptions; 74 | const { hash, useFallback = false } = fetchOptions; 75 | 76 | var $this = this; 77 | var driverInfo; 78 | try { 79 | driverInfo = this.getDriverInfo(originalPath, req, { 80 | hash, 81 | hashFromOptimizedOriginal, 82 | useFallback, 83 | }); 84 | } catch (ex) { 85 | this.emit('warn', ex); 86 | return void cb(ex); 87 | } 88 | 89 | // forward on to storage driver in case it supports isteam origin 90 | const useOriginal = urlInfo.query.useOriginal === 'true'; 91 | 92 | driverInfo.driver.fetch( 93 | { useOriginal, ...driverInfo.options }, 94 | driverInfo.realPath, 95 | hash, 96 | function (err, img, imgData) { 97 | if (err) { 98 | $this.emit('warn', err); 99 | 100 | if ( 101 | !useFallback && 102 | err.statusCode !== 404 && 103 | driverInfo.options.fallback 104 | ) { 105 | // if no explicit 404 and fallback desired, lets attempt 106 | // another fetch but using the fallback provider 107 | return void $this.fetch( 108 | req, 109 | reqOptions, 110 | { ...fetchOptions, useFallback: true }, 111 | cb 112 | ); 113 | } 114 | 115 | return void cb(err); 116 | } 117 | 118 | // backward compatible 119 | if (!(img instanceof Image)) { 120 | if (img && img.author) delete img.author; 121 | img = new Image(img, imgData); 122 | } 123 | 124 | cb(null, img); 125 | } 126 | ); 127 | }; 128 | 129 | p.store = function ( 130 | req, 131 | { originalPath, hashFromOptimizedOriginal }, 132 | { hash, touch, replica, options }, 133 | image, 134 | cb 135 | ) { 136 | cb = cb && _.once(cb); // account for flaky error handling within storage clients to avoid internal failures 137 | 138 | var driverInfo; 139 | try { 140 | driverInfo = this.getDriverInfo(originalPath, req, { 141 | hash, 142 | hashFromOptimizedOriginal, 143 | options, 144 | }); 145 | } catch (ex) { 146 | this.emit('warn', ex); 147 | return cb && cb(ex); 148 | } 149 | image.info.lastModified = new Date(); // auto-tracking of lastModified in meta unless storage client overrides 150 | 151 | driverInfo.driver[touch ? 'touch' : 'store']( 152 | driverInfo.options, 153 | driverInfo.realPath, 154 | hash, 155 | image, 156 | (err) => { 157 | if (err) { 158 | this.emit('warn', err); 159 | return cb && cb(err); 160 | } 161 | 162 | cb && cb(); 163 | } 164 | ); 165 | 166 | if (!touch && !replica) { 167 | // do not process replication for touches and recursion 168 | if (hash !== hashFromOptimizedOriginal) { 169 | this.artifactReplicas.forEach((replica) => 170 | this.store( 171 | req, 172 | { originalPath, hashFromOptimizedOriginal }, 173 | { hash, replica: true, options: replica }, 174 | image 175 | ) 176 | ); 177 | } else { 178 | this.optimizedReplicas.forEach((replica) => 179 | this.store( 180 | req, 181 | { originalPath, hashFromOptimizedOriginal }, 182 | { hash, replica: true, options: replica }, 183 | image 184 | ) 185 | ); 186 | } 187 | } 188 | }; 189 | 190 | p.deleteCache = function (req, { originalPath, useOptimized }, cb) { 191 | cb = _.once(cb); // account for flaky error handling within storage clients to avoid internal failures 192 | 193 | var $this = this; 194 | var driverInfo; 195 | try { 196 | driverInfo = this.getDriverInfo(originalPath, req, { 197 | hash: 'cache', 198 | hashFromOptimizedOriginal: useOptimized ? 'cache' : null, 199 | }); 200 | } catch (ex) { 201 | this.emit('warn', ex); 202 | return void cb(ex); 203 | } 204 | if (!driverInfo.driver.deleteCache) { 205 | const err = new Error( 206 | `deleteCache not supported on storage driver ${driverInfo.driver.name}` 207 | ); 208 | this.emit('warn', err); 209 | return void cb(err); 210 | } 211 | driverInfo.driver.deleteCache( 212 | driverInfo.options, 213 | driverInfo.realPath, 214 | function (err) { 215 | if (err) { 216 | $this.emit('warn', err); 217 | return void cb(err); 218 | } 219 | 220 | if (!useOptimized && $this.options.cacheOptimized) { 221 | // if optimized originals have their own cache, delete there as well 222 | return void $this.deleteCache( 223 | req, 224 | { originalPath, useOptimized: true }, 225 | cb 226 | ); 227 | } 228 | 229 | cb(); 230 | } 231 | ); 232 | }; 233 | 234 | p.getDriverInfo = function ( 235 | originalPath, 236 | req, 237 | { hash, hashFromOptimizedOriginal, options, useFallback = false } 238 | ) { 239 | var defaults = this.options.defaults || {}; 240 | var opts = defaults; 241 | var realPath = originalPath; 242 | 243 | var firstPart = this.options.app && originalPath.split('/')[0]; 244 | 245 | if (req.headers && req.headers['x-track-origin-referer']) { 246 | opts['x-track-origin-referer'] = req.headers['x-track-origin-referer']; 247 | } 248 | 249 | let prefix = ''; 250 | 251 | if (options) { 252 | // use explicit options if provided 253 | opts = _.merge({}, defaults, options); 254 | } else if ( 255 | !!hash && 256 | this.options.cacheOptimized && 257 | hash === hashFromOptimizedOriginal 258 | ) { 259 | // use cacheOptimized if enabled 260 | opts = _.merge({}, defaults, this.options.cacheOptimized); 261 | } else if (!!hash && this.options.cache) { 262 | // use cache if enabled 263 | opts = _.merge({}, defaults, this.options.cache); 264 | } else if (this.options.app && firstPart in this.options.app) { 265 | // if app match, use custom options 266 | prefix = firstPart; 267 | opts = _.merge({}, defaults, this.options.app[firstPart]); 268 | realPath = originalPath.substr(firstPart.length + 1); 269 | } else if (this.options.domain && req.headers.host in opts.domain) { 270 | // if domain match, use custom options 271 | prefix = req.headers.host; 272 | opts = _.merge({}, defaults, this.options.domain[prefix]); 273 | } else if ( 274 | this.options.header && 275 | req.headers['x-isteam-app'] in opts.header 276 | ) { 277 | // if `x-isteam-app` header match, use custom options 278 | prefix = req.headers['x-isteam-app']; 279 | opts = _.merge({}, defaults, opts.header[prefix]); 280 | } 281 | 282 | if (opts.fallback && useFallback) { 283 | // use fallback instead if available & requested 284 | if (opts.fallback in this.options.app) { 285 | opts = _.merge({}, defaults, this.options.app[opts.fallback]); 286 | } else { 287 | throw new Error( 288 | `Fallback of '${opts.fallback}' requested but does not exist` 289 | ); 290 | } 291 | } 292 | 293 | return { 294 | driver: this.getDriver(opts, prefix), 295 | options: opts, 296 | realPath: realPath, 297 | }; 298 | }; 299 | -------------------------------------------------------------------------------- /lib/storage/isteamb/12mp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/lib/storage/isteamb/12mp.jpeg -------------------------------------------------------------------------------- /lib/storage/isteamb/18mp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/lib/storage/isteamb/18mp.jpeg -------------------------------------------------------------------------------- /lib/storage/isteamb/24mp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/lib/storage/isteamb/24mp.jpeg -------------------------------------------------------------------------------- /lib/storage/isteamb/index.js: -------------------------------------------------------------------------------- 1 | const StorageBase = require('../storage-base'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | 5 | const fileNames = ['12mp.jpeg', '18mp.jpeg', '24mp.jpeg']; 6 | 7 | const fileData = fileNames.reduce((state, fn) => { 8 | state[fn] = fs.readFileSync(path.resolve(__dirname, fn)); 9 | return state; 10 | }, {}); 11 | 12 | module.exports = class StorageImageSteamBench extends StorageBase { 13 | constructor(opts) { 14 | super(opts); 15 | } 16 | 17 | fetch(opts, originalPath, stepsHash, cb) { 18 | const info = { path: originalPath, stepsHash: stepsHash }; 19 | 20 | const [filename] = originalPath.split('/'); 21 | const file = fileData[filename]; 22 | if (!file) { 23 | return void cb(new Error('File not found')); 24 | } 25 | 26 | cb(null, info, file); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /lib/storage/storage-base.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | module.exports = StorageBase; 4 | 5 | function StorageBase(options) { 6 | if (!(this instanceof StorageBase)) { 7 | return new StorageBase(options); 8 | } 9 | 10 | this.options = options || {}; 11 | } 12 | 13 | var p = StorageBase.prototype; 14 | 15 | p.fetch = function (options, originalPath, stepsHash, cb) { 16 | cb(new Error('not implemented')); 17 | }; 18 | 19 | p.store = function (options, originalPath, stepsHash, image, cb) { 20 | cb(new Error('not implemented')); 21 | }; 22 | 23 | p.touch = function (options, originalPath, stepsHash, image, cb) { 24 | // unless an optimal path is provided by storage client, overwrite the file 25 | this.store(options, originalPath, stepsHash, image, cb); 26 | }; 27 | 28 | p.getOptions = function (options) { 29 | return _.merge({}, this.options, options); 30 | }; 31 | -------------------------------------------------------------------------------- /misc/node16-avif-bug.js: -------------------------------------------------------------------------------- 1 | const sharp = require('sharp'); 2 | 3 | const IN_FILE = process.argv[2] || './test/files/steam-engine.jpg'; 4 | const OUT_FILE = process.argv[3] || './node16-bug.avif'; 5 | 6 | sharp(IN_FILE).avif().toFile(OUT_FILE); 7 | -------------------------------------------------------------------------------- /misc/node16-bug.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/misc/node16-bug.avif -------------------------------------------------------------------------------- /misc/orientation-bug.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs/promises'); 2 | const sharp = require('sharp'); 3 | 4 | /* 5 | imageSteps: [ 6 | { 7 | name: 'resize', 8 | width: 2560, 9 | height: 1920, 10 | max: 'true', 11 | canGrow: 'false' 12 | }, 13 | { name: 'quality', quality: '95' }, 14 | { name: 'metadata', enabled: 'true' }, 15 | { name: 'format', format: 'webp' } 16 | ] 17 | 18 | resize: 2560 1920 { 19 | interpolator: 'bicubic', 20 | fit: 'fill', 21 | position: 'centre', 22 | background: null 23 | } 24 | toFormat: webp { quality: 95 } 25 | */ 26 | 27 | (async () => { 28 | const inputBuffer = await fs.readFile('./test/files/Portrait_6.jpg'); 29 | //const inputBuffer = await fs.readFile('./test/files/IMG_20211014_091852131.jpeg'); 30 | 31 | const noMetaBuffer = await sharp(inputBuffer).resize(2560, 1920, { 32 | interpolator: 'bicubic', 33 | fit: 'fill', 34 | position: 'centre', 35 | background: null 36 | }).toFormat('webp', { quality: 95 }).toBuffer(); 37 | await fs.writeFile('./image-no-meta.webp', noMetaBuffer); 38 | 39 | const withMetaBuffer = await sharp(inputBuffer).resize(2560, 1920, { 40 | interpolator: 'bicubic', 41 | fit: 'fill', 42 | position: 'centre', 43 | background: null 44 | }).toFormat('webp', { quality: 95 }).withMetadata().toBuffer(); 45 | await fs.writeFile('./image-with-meta.webp', withMetaBuffer); 46 | })(); 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-steam", 3 | "version": "0.64.6", 4 | "description": "A simple, fast, and highly customizable realtime image manipulation web server built atop Node.js.", 5 | "main": "index.js", 6 | "scripts": { 7 | "bench": "./packages/image-steam-bench/bin/isteamb run http://localhost:13337/isteamb", 8 | "check-cov": "nyc check-coverage --statements 70 --functions 75 --branches 50 --lines 70 || node scripts/launch-coverage-in-browser", 9 | "start": "node ./scripts/server.js --isConfig ./scripts/dev.js --isDemo true", 10 | "nodemo": "node ./scripts/server.js --isConfig ./scripts/dev.js", 11 | "mocha": "mocha -w -R spec", 12 | "prettier": "npx prettier --write lib test", 13 | "report": "nyc report --reporter=cobertura && nyc report --reporter=lcov", 14 | "test": "npm run prettier && npm run test-and-check && npm outdated", 15 | "test-and-check": "npm run unit && npm run report && npm run check-cov", 16 | "unit": "npx nyc ./node_modules/mocha/bin/_mocha" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/asilvas/node-image-steam.git" 21 | }, 22 | "keywords": [ 23 | "image", 24 | "steam", 25 | "processor" 26 | ], 27 | "author": { 28 | "name": "Aaron Silvas" 29 | }, 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/asilvas/node-image-steam/issues" 33 | }, 34 | "homepage": "https://github.com/asilvas/node-image-steam#readme", 35 | "bin": { 36 | "image-steam": "./bin/isteam", 37 | "isteam": "./bin/isteam", 38 | "isteamd": "./bin/isteamd" 39 | }, 40 | "engines": { 41 | "node": ">=14.3.0" 42 | }, 43 | "files": [ 44 | "bin", 45 | "lib", 46 | "index.js", 47 | "LICENSE.txt", 48 | "README.md", 49 | "CHANGELOG.md", 50 | "package.json" 51 | ], 52 | "dependencies": { 53 | "agentkeepalive": "^4.2.1", 54 | "async": "^3.2.3", 55 | "fs-extra": "^10.0.1", 56 | "image-pal-sharp": "^1.2.2", 57 | "lodash": "^4.17.21", 58 | "mime": "^3.0.0", 59 | "semaphore": "^1.1.0", 60 | "sharp": "^0.33.3", 61 | "xxhash": "^0.3.0", 62 | "yargs": "^17.3.1" 63 | }, 64 | "devDependencies": { 65 | "browser-launcher": "^4.0.0", 66 | "chai": "^4.3.6", 67 | "mocha": "^10.0.0", 68 | "nyc": "^15.1.0", 69 | "open": "^8.4.0", 70 | "prettier": "^2.5.1", 71 | "should": "^13.2.3", 72 | "sinon": "^14.0.0", 73 | "sinon-chai": "^3.7.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/image-steam-bench/.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | *.DS_Store 11 | 12 | pids 13 | logs 14 | results 15 | 16 | npm-debug.log 17 | 18 | # Netbeans 19 | /nbproject/ 20 | 21 | # Webstorm Settings 22 | .idea/ 23 | !.idea/inspectionProfiles 24 | !.idea/jsLinters 25 | 26 | # Istanbul Output 27 | coverage*/ 28 | 29 | # NPM Dependencies 30 | node_modules/ 31 | 32 | # Visual Studio files 33 | *.suo 34 | *.sln 35 | *.nsproj 36 | 37 | # Sublime Project files 38 | *.sublime-project 39 | *.sublime-workspace 40 | 41 | # Eclipse 42 | .project 43 | -------------------------------------------------------------------------------- /packages/image-steam-bench/.npmrc: -------------------------------------------------------------------------------- 1 | registry=http://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /packages/image-steam-bench/README.md: -------------------------------------------------------------------------------- 1 | # isteamb 2 | 3 | Benchmark for [Image Steam](https://github.com/asilvas/node-image-steam) to help determine ideal hardware configurations and load levels. Designed to support load testing of large configurations, where every simulated user has it's own dedicated thread, and the origin proxy has it's own dedicated process. 4 | 5 | ![Dashboard](https://raw.githubusercontent.com/asilvas/node-image-steam/master/packages/image-steam-bench/docs/dashboard.jpg) 6 | 7 | 8 | ## Getting Started 9 | 10 | * Add `isteamb` HTTP mapping to your [Image-Steam configuration](https://github.com/asilvas/node-image-steam/blob/master/scripts/dev.js#L50-L55), allowing this benchmark to act as origin. 11 | * Install via `npm i -g image-steam-bench` 12 | * Run benchmark via `isteamb run http://localhost:8080/isteamb --port 12124`, where the URL is pointing to the Image-Steam endpoint, and the port is where Image-Steam is [configured](https://github.com/asilvas/node-image-steam/blob/master/scripts/dev.js#L50-L55) to connect to for `isteamb` requests. 13 | 14 | ### CLI Options 15 | 16 | * `port` (default: `12124`) - Port for image-steam-bench to listen on 17 | (same port image-steam should be mapped back to). 18 | * `format` (default: `webp`) - Image format requested in every test. 19 | * `test` (default: `origin optimized cached real-90 real-95`) - One or more tests to run. 20 | * `minLoad` (default: `1.25`) - Increase in mean response times before considered minimum safe load. 21 | * `maxLoad` (default: `2.0`) - Max load is determined by optimal TTFB multiplied by this value. 22 | * `minRunTime` (default: `20000`) - Minimum time (in ms) that a test must run before determinating. 23 | If `requests` is specified that will take priority. 24 | * `requests` - A fixed number of requests before resolving test(s), versus the default 25 | behavior of ending on `maxLoad`. 26 | * `workerMin` (default: `1`) - Number of workers to start out with. 27 | * `workerMax` (default: `999`) - Maximum number of workers allowed. 28 | * `workerSpawnTime` (default: `3`) - Seconds before new workers are spawned. 29 | * `workerSpawnRate` (default: `0.2`) - The rate at which workers are spawned (0.2 being +20% per spawn). 30 | * `screenRefresh` (default: `1`) - Seconds between updates. 31 | * `timeWindow` (default: `30`) - Seconds represented on historical graphs. 32 | * `log` (default: `isteamb.log`) - Filename of activity log, or `false` to disable. 33 | 34 | 35 | ## Tests 36 | 37 | Gradual increase in concurrency load until errors or timeouts begin to occur, starting with a concurrency of 1 which is a measure of performance instead of load. 38 | 39 | * Original - Worst case performance where nothing is cached. 40 | * Optimized - Optimized originals available, but image artifacts must still be created. 41 | * Cached - Best case scenario, no image operations, pure cache throughput. 42 | * Real 90/8/2 - Emulation of more realworld environment where 90% of hits are cached, and 8% use optimized originals to create final image artifact, and 2% hit original and create both optimized original and image artifact. 43 | * Real 95/4/1 - Emulation of more realworld environment where 95% of hits are cached, and 4% use optimized originals to create final image artifact, and 1% hit original and create both optimized original and image artifact. 44 | 45 | ### Final Scores 46 | 47 | Calculated for each of the tests. 48 | 49 | * Performance (50th/75th/90th TTFB ms) - Single concurrency score to demonstrate raw performance. 50 | * Minimum Load (req/sec @ concurrency, 50th ms) - The level of load before per-request 51 | response times begin to creep up. 52 | * Optimal Load (req/sec @ concurrency, 50th ms) - The level of load that is considered ideal maximum 53 | before throughput begins to drop. 54 | 55 | 56 | 57 | ## Dashboard 58 | 59 | A number of real-time data points are available from the dashboard. 60 | 61 | * Throughput (per/sec) - Historical graph 62 | * Errors (per/sec) / Concurrency - Historical graph 63 | * Latency (ms) - Historical graph 64 | * Test Progress (%) - Progress of all tests 65 | * Scores - See `Final Scores`. 66 | * Activity Log 67 | 68 | 69 | ## Definitions 70 | 71 | * `rps` - Requests per second. 72 | * `cc` - Concurrency, the number of tasks taking place at the same time. 73 | In the context of `isteamb`, each task resides on its own worker thread. 74 | * `Kb` - Kilobit, or 1000 bits, or 125 bytes. 75 | * `Mb` - Magabit, or 1000 kilobits, or 125 kilobytes. 76 | * `Gb` - Kilobit, or 1000 megabits, or 125 megabytes. 77 | * `/s` - Per second. 78 | * `TTFB` - Time to first byte. The time it takes from initiating a task to receiving the first data of the response. 79 | This may also be referred to as latency or response time. 80 | * `50th` - The sorted 50th percentile of data, or the "mean". 81 | * `75th` - The sorted 75th percentile of data, or the 25th percentile highest data point. 82 | * `90th` - The sorted 90th percentile of data, or the 10th percentile highest data point. 83 | 84 | 85 | ## Files 86 | 87 | A dedicated worker process, serving files from memory to emulate high throughput origin. 88 | 89 | * `http://localhost:12124/isteamb/12mp.jpeg/*` - 12 magapixel (4256x2832) asset 90 | * `http://localhost:12124/isteamb/18mp.jpeg/*` - 18 magapixel (5184x3456) asset 91 | * `http://localhost:12124/isteamb/24mp.jpeg/*` - 24 magapixel (6016x4016) asset 92 | 93 | 94 | ### Credit 95 | 96 | * `12mp.jpeg` - Photo by NASA on Unsplash 97 | * `18mp.jpeg` - Photo by Justin Clark on Unsplash 98 | * `24mp.jpeg` - Photo by Casey Horner on Unsplash 99 | -------------------------------------------------------------------------------- /packages/image-steam-bench/bin/isteamb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../'); 4 | -------------------------------------------------------------------------------- /packages/image-steam-bench/docs/dashboard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/packages/image-steam-bench/docs/dashboard.jpg -------------------------------------------------------------------------------- /packages/image-steam-bench/files/12mp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/packages/image-steam-bench/files/12mp.jpeg -------------------------------------------------------------------------------- /packages/image-steam-bench/files/18mp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/packages/image-steam-bench/files/18mp.jpeg -------------------------------------------------------------------------------- /packages/image-steam-bench/files/24mp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/packages/image-steam-bench/files/24mp.jpeg -------------------------------------------------------------------------------- /packages/image-steam-bench/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-steam-bench", 3 | "version": "1.5.0", 4 | "description": "Benchmark for Image Steam to help determine ideal hardware configurations and load levels", 5 | "main": "src/cli.js", 6 | "scripts": { 7 | "start": "node ./ run http://localhost:13337/isteamb", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "bin": { 11 | "image-steam-bench": "./bin/isteamb", 12 | "isteamb": "./bin/isteamb" 13 | }, 14 | "files": [ 15 | "bin", 16 | "files", 17 | "src", 18 | "README.md", 19 | "package.json" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/asilvas/node-image-steam/tree/master/packages/image-steam-bench" 24 | }, 25 | "author": "Aaron Silvas", 26 | "license": "MIT", 27 | "dependencies": { 28 | "blessed": "^0.1.81", 29 | "blessed-contrib": "^4.8.16", 30 | "yargs": "^14.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/bench.js: -------------------------------------------------------------------------------- 1 | const Screen = require('./screen'); 2 | 3 | module.exports = class Bench { 4 | constructor(argv) { 5 | this.argv = argv; 6 | this.testReset(); 7 | this.scores = {}; 8 | 9 | this.screen = new Screen(this); 10 | } 11 | 12 | log(msg, type = 'log') { 13 | this.screen.log(msg, type); 14 | } 15 | 16 | testStart(name) { 17 | this.testName = name; 18 | this.concurrency = 0; 19 | this.rps = 0; 20 | this.eps = 0; // errors per second 21 | this.kbps = 0; // Kbit/s 22 | this.ttfb50th = 0; 23 | this.ttfb75th = 0; 24 | this.ttfb90th = 0; 25 | this.log(`${this.testName} starting...`); 26 | this.testReset(); 27 | this.scores[this.testName] = this.testData.score; 28 | } 29 | 30 | testReset() { 31 | this.testData = { 32 | start: Date.now(), 33 | lastUpdate: Date.now(), 34 | lastOptimal: Date.now(), 35 | ttfbMean: 0, 36 | score: { 37 | perf: { ttfb50th: 0, ttfb75th: 0, ttfb90th: 0, kbps: 0 }, 38 | min: { rps: 0, ttfb: 0, concurrency: 0, kbps: 0 }, 39 | max: { rps: 0, ttfb: 0, concurrency: 0, kbps: 0 }, 40 | optimal: { rps: 0, ttfb: 0, concurrency: 0, kbps: 0 } 41 | }, 42 | isOver: false, 43 | requests: [], 44 | errors: 0, 45 | lastTickRequests: [], 46 | lastTickErrors: 0 47 | }; 48 | } 49 | 50 | testEnd() { 51 | this.log(`${this.testName} complete`); 52 | 53 | this.testReset(); 54 | this.testName = null; 55 | } 56 | 57 | onTestData({ workerIndex }, { requests, errors }) { 58 | this.testData.lastTickRequests = this.testData.lastTickRequests.concat(requests); 59 | this.testData.lastTickErrors += errors; 60 | } 61 | 62 | get testIsOver() { 63 | return this.testData.isOver; 64 | } 65 | 66 | updateScreen() { 67 | this.updateTestStats(); 68 | 69 | this.screen.render(); 70 | } 71 | 72 | updateTestStats() { 73 | if (this.testData.isOver) return; 74 | 75 | // don't process fewer than 8 updates at a time 76 | if (!this.testData.isOver && this.testData.lastTickRequests.length < 8) return; 77 | 78 | this.testData.errors += this.testData.lastTickErrors; 79 | 80 | const ttfbSorted = this.testData.lastTickRequests.sort((a, b) => a.ttfb < b.ttfb ? -1 : 1); 81 | const ttfb50th = this.ttfb50th = ttfbSorted[Math.floor(ttfbSorted.length / 2)].ttfb; 82 | const ttfb75th = this.ttfb75th = ttfbSorted[Math.floor(ttfbSorted.length * 0.75)].ttfb; 83 | const ttfb90th = this.ttfb90th = ttfbSorted[Math.floor(ttfbSorted.length * 0.90)].ttfb; 84 | const elapsed = Date.now() - this.testData.lastUpdate; 85 | const rpsFactor = 1000 / elapsed; 86 | const rps = this.rps = Math.round(this.testData.lastTickRequests.length * rpsFactor); 87 | this.eps = Math.round(this.testData.lastTickErrors * rpsFactor); 88 | const totalKb = ttfbSorted.reduce((total, { size }) => { 89 | return total + ((size * 8) / 1000); // bits / kilo 90 | }, 0); 91 | const kbps = this.kbps = Math.round(totalKb * rpsFactor); 92 | const timeSinceStart = Date.now() - this.testData.start; 93 | const timeSinceLastOptimal = Date.now() - this.testData.lastOptimal; 94 | 95 | this.testData.requests = this.testData.requests.concat(this.testData.lastTickRequests); 96 | 97 | if (!this.testData.ttfbMean) { 98 | this.testData.score.perf.ttfb50th = ttfb50th; 99 | this.testData.score.perf.ttfb75th = ttfb75th; 100 | this.testData.score.perf.ttfb90th = ttfb90th; 101 | this.testData.score.perf.kbps = kbps; 102 | 103 | this.testData.ttfbMean = ttfb50th; 104 | } 105 | 106 | if (!this.testData.score.min.ttfb && ttfb50th >= (this.testData.score.perf.ttfb50th * this.argv.minLoad)) { 107 | this.testData.score.min.ttfb = ttfb50th; 108 | this.testData.score.min.rps = rps; 109 | this.testData.score.min.concurrency = this.concurrency; 110 | this.testData.score.min.kbps = kbps; 111 | } 112 | 113 | // optimal load is the point of highest throughput 114 | if (!this.testData.score.optimal.rps || rps > this.testData.score.optimal.rps) { 115 | this.testData.lastOptimal = Date.now(); 116 | this.testData.score.optimal.ttfb = ttfb50th; 117 | this.testData.score.optimal.rps = rps; 118 | this.testData.score.optimal.concurrency = this.concurrency; 119 | this.testData.score.optimal.kbps = kbps; 120 | this.testData.score.max.ttfb = 0; // reset anytime new optimal level detected 121 | } else if (!this.argv.requests && timeSinceLastOptimal > this.argv.minRunTime) { 122 | // if we're not seeing optimal changes for quite a while, end test 123 | this.testData.isOver = true; 124 | } 125 | 126 | // max load is determined by 2x latency (or whatever `maxLoad` is set to) of optimal TTFB 127 | const maxLoadLatency = Math.max(20, Math.floor(this.testData.score.optimal.ttfb * this.argv.maxLoad)); 128 | 129 | // fixed request option takes priority over default `maxLoad` behavior 130 | if (this.argv.requests && this.testData.requests.length >= this.argv.requests) { 131 | if (!this.testData.score.min.ttfb) { // if no minimum yet set, do it now 132 | this.testData.score.min.ttfb = ttfb50th; 133 | this.testData.score.min.rps = rps; 134 | this.testData.score.min.concurrency = this.concurrency; 135 | this.testData.score.min.kbps = kbps; 136 | } 137 | 138 | this.testData.isOver = true; 139 | } 140 | 141 | // take the first round that exceeds threshold, then end it 142 | if (!this.argv.requests && timeSinceStart > this.argv.minRunTime && !this.testData.score.max.ttfb && ttfb50th >= maxLoadLatency) { 143 | this.testData.score.max.ttfb = ttfb50th; 144 | this.testData.score.max.rps = rps; 145 | this.testData.score.max.concurrency = this.concurrency; 146 | this.testData.score.max.kbps = kbps; 147 | this.testData.isOver = true; 148 | } 149 | 150 | // if the test is over but min/max have not yet been determined, set to current state 151 | if (this.testData.isOver) { 152 | if (!this.testData.score.min.ttfb) { 153 | this.testData.score.min.ttfb = ttfb50th; 154 | this.testData.score.min.rps = rps; 155 | this.testData.score.min.concurrency = this.concurrency; 156 | this.testData.score.min.kbps = kbps; 157 | } 158 | if (!this.testData.score.min.ttfb) { 159 | this.testData.score.max.ttfb = ttfb50th; 160 | this.testData.score.max.rps = rps; 161 | this.testData.score.max.concurrency = this.concurrency; 162 | this.testData.score.max.kbps = kbps; 163 | } 164 | } 165 | 166 | this.scores[this.testName] = this.testData.score; 167 | 168 | // reset tracker 169 | this.testData.lastUpdate = Date.now(); 170 | this.testData.lastTickRequests = []; 171 | this.testData.lastTickErrors = 0; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/cli.js: -------------------------------------------------------------------------------- 1 | const yargs = require('yargs'); 2 | const run = require('./run'); 3 | const tests = require('./test'); 4 | 5 | const args = yargs 6 | .option('port', { 7 | alias: 'p', 8 | type: 'number', 9 | describe: 'Port for image-steam-bench to listen on (same port image-steam should be mapped back to)', 10 | default: 12124 11 | }) 12 | .option('test', { 13 | alias: 't', 14 | type: 'array', 15 | choices: [...tests], 16 | describe: 'one or more tests to run', 17 | default: [...tests] 18 | }) 19 | .option('format', { 20 | alias: 'f', 21 | type: 'string', 22 | choices: ['webp', 'avif', 'jpeg'], 23 | describe: 'Image format requested in every test', 24 | default: 'webp' 25 | }) 26 | .option('minLoad', { 27 | type: 'number', 28 | describe: 'Increase in 50th response times before considered minimum safe load', 29 | default: 1.25 30 | }) 31 | .option('maxLoad', { 32 | type: 'number', 33 | describe: 'Max load is determined by optimal TTFB multiplied by this value', 34 | default: 2.5 35 | }) 36 | .option('minRunTime', { 37 | type: 'number', 38 | describe: 'Minimum time (in ms) that a test must run before determinating. If `requests` is specified that will take priority', 39 | default: 15000 40 | }) 41 | .option('requests', { 42 | type: 'number', 43 | describe: 'A fixed number of requests before resolving test(s), versus the default behavior of ending on `maxLoad`' 44 | }) 45 | .option('workerMin', { 46 | type: 'number', 47 | describe: 'Number of workers to start out with', 48 | default: 1 49 | }) 50 | .option('workerMax', { 51 | type: 'number', 52 | describe: 'Maximum number of workers allowed', 53 | default: 999 54 | }) 55 | .option('workerSpawnTime', { 56 | type: 'number', 57 | describe: 'Seconds before new workers are spawned', 58 | default: 5 59 | }) 60 | .option('workerSpawnRate', { 61 | type: 'number', 62 | describe: 'The rate at which workers are spawned (0.2 being +20% per spawn)', 63 | default: 0.25 64 | }) 65 | .option('screenRefresh', { 66 | type: 'number', 67 | describe: 'Seconds between updates', 68 | default: 1 69 | }) 70 | .option('timeWindow', { 71 | type: 'number', 72 | describe: 'Seconds represented on historical graphs', 73 | default: 40 74 | }) 75 | .option('log', { 76 | type: 'string', 77 | describe: 'Filename of activity log, or false to disable', 78 | default: 'isteamb.log' 79 | }) 80 | .command(run) 81 | .demandCommand() 82 | .help() 83 | .argv 84 | ; 85 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/files.js: -------------------------------------------------------------------------------- 1 | const byIndex = ['12mp.jpeg', '18mp.jpeg', '24mp.jpeg']; 2 | const byKeys = byIndex.reduce((state, fn, idx) => { 3 | state[fn] = idx; 4 | return state; 5 | }, {}); 6 | 7 | module.exports = { 8 | byKeys, 9 | byIndex 10 | } 11 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/run-test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const sleep = require('./util/sleep'); 3 | const files = require('./files'); 4 | const { 5 | Worker 6 | } = require('worker_threads'); 7 | 8 | const testDir = path.resolve(__dirname, 'test'); 9 | let gReady; 10 | 11 | module.exports = async (bench, testName) => { 12 | 13 | const workers = []; 14 | 15 | gReady = false; 16 | const benchKey = new Date().toLocaleString(); 17 | 18 | bench.testStart(testName); 19 | 20 | for (let i = 0; i < bench.argv.workerMin; i++) { 21 | workers.push(spawnWorker(bench, testName, { benchKey, workerIndex: workers.length })); 22 | } 23 | bench.concurrency = workers.length; 24 | 25 | do { 26 | await sleep(500); 27 | } while (!gReady); 28 | 29 | bench.testReset(); 30 | 31 | bench.log(`${testName} running...`); 32 | 33 | let screenUpdate = 0; 34 | 35 | do { 36 | for (let i = 0; i < bench.argv.workerSpawnTime; i++) { 37 | await sleep(1000); 38 | 39 | if ((screenUpdate++ % bench.argv.screenRefresh) === 0) { 40 | bench.updateScreen(); 41 | } 42 | } 43 | 44 | let workersToSpawn = Math.ceil(workers.length * bench.argv.workerSpawnRate) || 1; 45 | for (let i = 0; i < workersToSpawn; i++) { 46 | if (workers.length >= bench.argv.workerMax) break; 47 | 48 | workers.push(spawnWorker(bench, testName, { benchKey, workerIndex: workers.length })); 49 | } 50 | 51 | bench.concurrency = workers.length; 52 | } while (!bench.testIsOver) 53 | 54 | bench.log(`${testName} wrapping up...`); 55 | 56 | // destroy workers 57 | workers.forEach(worker => worker.terminate()); 58 | 59 | await sleep(2000); 60 | 61 | bench.testEnd(); 62 | } 63 | 64 | function spawnWorker(bench, testName, { workerIndex, benchKey }) { 65 | const workerPath = path.resolve(testDir, `${testName}.js`); 66 | // a given worker is locked to the same filename 67 | const fileName = files.byIndex[workerIndex % 3]; 68 | const baseUrl = `${bench.argv.url}/${fileName}/${benchKey}`; 69 | const worker = new Worker(workerPath, { workerData: { argv: bench.argv, baseUrl, fileName, workerIndex } }); 70 | worker.on('message', data => { 71 | if (data.ready) gReady = true; 72 | if (data.requests) bench.onTestData({ testName, workerIndex }, data); 73 | }); 74 | worker.on('error', err => { 75 | bench.log(err, 'error'); 76 | }); 77 | 78 | return worker; 79 | } 80 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/run.js: -------------------------------------------------------------------------------- 1 | const Bench = require('./bench'); 2 | const server = require('./server'); 3 | const verifyISteam = require('./verify-isteam'); 4 | const runTest = require('./run-test'); 5 | const testsAvailable = require('./test'); 6 | 7 | module.exports = { 8 | command: 'run ', 9 | desc: 'Begin benchmark', 10 | handler: async argv => { 11 | try { 12 | 13 | const bench = new Bench(argv); 14 | 15 | const closeServer = await server(bench); 16 | 17 | await verifyISteam(bench); 18 | 19 | for (var i = 0; i < argv.test.length; i++) { 20 | await runTest(bench, argv.test[i]); 21 | } 22 | 23 | bench.log('Tests complete.'); 24 | 25 | await closeServer(); 26 | 27 | bench.log('Press ESC or Q to quit.'); 28 | bench.updateScreen(); 29 | } catch (ex) { 30 | console.error('Something went wrong!', ex.stack || ex.message || ex); 31 | console.log('Press ESC or Q to quit.'); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/screen.js: -------------------------------------------------------------------------------- 1 | const blessed = require('blessed'); 2 | const contrib = require('blessed-contrib'); 3 | const { writeFileSync, appendFileSync } = require('fs'); 4 | 5 | module.exports = class Screen { 6 | constructor(bench) { 7 | this.bench = bench; 8 | 9 | if (this.bench.argv.log !== 'false') writeFileSync(this.bench.argv.log, 'Started\n'); 10 | 11 | this.screen = blessed.screen(); 12 | 13 | process.on('uncaughtException', err => { 14 | this.log(err.stack || err.message || err); 15 | 16 | process.exit(); 17 | }); 18 | 19 | process.on('unhandledRejection', (reason, promise) => { 20 | this.log('unhandledRejection'); 21 | 22 | process.exit(); 23 | }); 24 | 25 | this.screen.key(['escape', 'q', 'C-c'], (ch, key) => { 26 | this.destroy(); 27 | 28 | // output scores 29 | this.printScoreDataAsMarkup(); 30 | 31 | console.log(''); 32 | 33 | return process.exit(0); 34 | }); 35 | 36 | this.grid = new contrib.grid({ rows: 12, cols: 12, screen: this.screen }); 37 | 38 | this.screenLog = this.grid.set(8, 8, 4, 4, contrib.log, { 39 | padding: 1, 40 | fg: 'white', 41 | selectedFg: 'green', 42 | label: 'Activity Log' 43 | }); 44 | 45 | this.screenThroughput = this.grid.set(0, 0, 8, 8, contrib.line, { 46 | style: { 47 | text: 'white', 48 | baseline: 'black' 49 | }, 50 | xLabelPadding: 3, 51 | xPadding: 5, 52 | showLegend: false, 53 | wholeNumbersOnly: true, 54 | numYLabels: 8, 55 | showNthLabel: 5, 56 | legend: { width: 20 }, 57 | label: 'Throughput (rps)' 58 | }); 59 | 60 | // hacky labels due to limitations of line widget 61 | this.timeLabels = Array.from(Array(this.bench.argv.timeWindow)).map((v, i) => ` `); 62 | this.timeLabels[Math.floor(this.bench.argv.timeWindow / 2)] = 'time'; 63 | 64 | this.throughputData = { 65 | style: { line: 'green' }, 66 | x: this.timeLabels, 67 | y: Array.from(Array(this.bench.argv.timeWindow)).map(() => 0) 68 | }; 69 | 70 | this.screenLatency = this.grid.set(4, 8, 4, 4, contrib.line, { 71 | style: { 72 | text: 'white', 73 | baseline: 'black' 74 | }, 75 | xLabelPadding: 3, 76 | xPadding: 5, 77 | showLegend: false, 78 | wholeNumbersOnly: true, 79 | numYLabels: 4, 80 | showNthLabel: 5, 81 | legend: { width: 20 }, 82 | label: 'Latency (50th / 75th / 90th)' 83 | }); 84 | 85 | this.latencyData50th = { 86 | style: { line: 'green' }, 87 | x: this.timeLabels, 88 | y: Array.from(Array(this.bench.argv.timeWindow)).map(() => 0) 89 | }; 90 | this.latencyData75th = { 91 | style: { line: 'yellow' }, 92 | x: this.timeLabels, 93 | y: Array.from(Array(this.bench.argv.timeWindow)).map(() => 0) 94 | }; 95 | this.latencyData90th = { 96 | style: { line: 'red' }, 97 | x: this.timeLabels, 98 | y: Array.from(Array(this.bench.argv.timeWindow)).map(() => 0) 99 | }; 100 | 101 | this.screenErrorsConcurrency = this.grid.set(0, 8, 4, 4, contrib.line, { 102 | style: { 103 | text: 'white', 104 | baseline: 'black' 105 | }, 106 | xLabelPadding: 3, 107 | xPadding: 5, 108 | showLegend: false, 109 | wholeNumbersOnly: true, 110 | numYLabels: 4, 111 | showNthLabel: 5, 112 | legend: { width: 15 }, 113 | label: 'Concurrency / Errors' 114 | }); 115 | 116 | this.errorsData = { 117 | title: 'Errors', 118 | style: { line: 'red' }, 119 | x: this.timeLabels, 120 | y: Array.from(Array(this.bench.argv.timeWindow)).map(() => 0) 121 | }; 122 | this.concurrencyData = { 123 | title: 'Concurrency', 124 | style: { line: 'yellow' }, 125 | x: this.timeLabels, 126 | y: Array.from(Array(this.bench.argv.timeWindow)).map(() => 0) 127 | }; 128 | 129 | this.screenScores = this.grid.set(8, 0, 4, 8, contrib.table, { 130 | keys: true, 131 | fb: 'white', 132 | label: 'Test Scores', 133 | columnSpacing: 1, 134 | columnWidth: [10, 10, 40, 40, 40] 135 | }); 136 | this.scoreHeaders = [ 137 | 'Name', 138 | 'Status', 139 | 'Baseline 50th, 75th, 90th', 140 | 'Min (Safe) Load', 141 | 'Optimal (Peak) Load'/*, 142 | 'Max (Break) Load'*/ 143 | ]; 144 | 145 | } 146 | 147 | log(msg) { 148 | const line = msg.toString(); 149 | 150 | if (this.bench.argv.log !== 'false') appendFileSync(this.bench.argv.log, line + '\n'); 151 | 152 | this.screenLog.log(line); 153 | this.screenLog.select(this.screenLog.logLines.length - 1); // unsure why this isn't showing selected 154 | } 155 | 156 | get scores() { return this.bench.scores; } 157 | get testData() { return this.bench.testData; } 158 | 159 | render() { 160 | this.throughputData.y.push(this.bench.rps); 161 | this.throughputData.y.shift(); 162 | this.screenThroughput.setData([this.throughputData]); 163 | 164 | this.latencyData50th.y.push(this.bench.ttfb50th); 165 | this.latencyData50th.y.shift(); 166 | this.latencyData75th.y.push(this.bench.ttfb75th); 167 | this.latencyData75th.y.shift(); 168 | this.latencyData90th.y.push(this.bench.ttfb90th); 169 | this.latencyData90th.y.shift(); 170 | this.screenLatency.setData([this.latencyData50th, this.latencyData75th, this.latencyData90th]); 171 | 172 | this.errorsData.y.push(this.bench.eps); 173 | this.errorsData.y.shift(); 174 | this.concurrencyData.y.push(this.bench.concurrency); 175 | this.concurrencyData.y.shift(); 176 | this.screenErrorsConcurrency.setData([this.errorsData, this.concurrencyData]); 177 | 178 | this.screenScores.setData({ 179 | headers: this.scoreHeaders, 180 | data: this.getScoreData() 181 | }); 182 | this.screenScores.rows.select(this.scoreIndex); 183 | 184 | this.screen.render(); 185 | } 186 | 187 | getScoreData() { 188 | this.scoreIndex = 0; 189 | 190 | return this.bench.argv.test.map((testName, idx) => { 191 | let scores = this.scores[testName]; 192 | 193 | if (scores && scores.max.rps) this.scoreIndex = idx + 1; 194 | 195 | const state = scores && scores.max.rps ? 'complete' : this.bench.testName === testName ? 'working' : 'pending'; 196 | 197 | const perf = scores && scores.perf.ttfb50th ? `${scores.perf.ttfb50th}ms, ${scores.perf.ttfb75th}ms, ${scores.perf.ttfb90th}ms, ${kbpsToString(scores.perf.kbps)}` : ''; 198 | const minLoad = scores && scores.min.rps ? `${scores.min.rps}rps, ${scores.min.concurrency}cc, ${scores.min.ttfb}ms, ${kbpsToString(scores.min.kbps)}` : ''; 199 | const optimalLoad = scores && scores.optimal.rps ? `${scores.optimal.rps}rps, ${scores.optimal.concurrency}cc, ${scores.optimal.ttfb}ms, ${kbpsToString(scores.optimal.kbps)}` : ''; 200 | 201 | return [testName, state, perf, minLoad, optimalLoad]; 202 | }); 203 | } 204 | 205 | printScoreDataAsMarkup() { 206 | const markup = this.getScoreData().reduce((state, test) => { 207 | state.push(test.map(v => `| ${v} `).join('') + '|'); 208 | return state; 209 | }, [ 210 | this.scoreHeaders.map(h => `| ${h} `).join('') + '|', 211 | this.scoreHeaders.map(() => `| --- `).join('') + '|' 212 | ]); 213 | 214 | console.log('Test Scores:'); 215 | markup.forEach(line => console.log(line)); 216 | } 217 | 218 | destroy() { 219 | this.screen.destroy(); 220 | } 221 | } 222 | 223 | function kbpsToString(kbps) { 224 | const mbps = kbps / 1000; 225 | const gbps = mbps / 1000; 226 | if (mbps < 1) return `${kbps.toFixed(1)}Kb/s`; 227 | else if (gbps < 1) return `${mbps.toFixed(1)}Mb/s`; 228 | else return `${gbps.toFixed(1)}Gb/s`; 229 | } 230 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/server-process.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const files = require('./files'); 5 | 6 | const gFiles = {}; 7 | 8 | console.log('Starting server...'); 9 | 10 | files.byIndex.forEach(fn => { 11 | gFiles[fn] = fs.readFileSync(path.resolve(__dirname, '..', 'files', fn)); 12 | }); 13 | 14 | const server = http.createServer(httpHandler); 15 | 16 | const port = process.env.PORT || 12124; 17 | server.listen(port, err => { 18 | if (err) { 19 | return void console.error(err.stack); 20 | } 21 | 22 | console.log(`Listening on ${port}`); 23 | }) 24 | 25 | function httpHandler(req, res) { 26 | if (req.method !== 'GET') { 27 | res.statusCode = 405; 28 | return void res.end(); 29 | } 30 | 31 | const [,isteamb, filename] = req.url.split('/'); 32 | const file = gFiles[filename]; 33 | if (isteamb !== 'isteamb' || !file) { 34 | res.statusCode = 404; 35 | return void res.end(); 36 | } 37 | 38 | res.setHeader('Content-Type', 'image/jpeg'); 39 | 40 | res.end(file); 41 | } 42 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/server.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process'); 2 | const path = require('path'); 3 | 4 | module.exports = async bench => { 5 | bench.log('Spawning server process...'); 6 | 7 | // spawn server on another process to avoid thread constraint with screen updates under heavy load 8 | const serverProcess = spawn('node', [path.resolve(__dirname, './server-process.js')], { 9 | env: { 10 | ...process.env, 11 | PORT: bench.argv.port 12 | }, 13 | detached: false, 14 | windowsHide: true 15 | }); 16 | 17 | return new Promise(resolve => { 18 | // dumb auto-resolve for now to permit server to do its prep before listening 19 | setTimeout(() => resolve(() => { 20 | serverProcess.kill(); 21 | }), 2000); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/test/cached.js: -------------------------------------------------------------------------------- 1 | const { 2 | parentPort, workerData 3 | } = require('worker_threads'); 4 | const request = require('../util/blind-request'); 5 | const sleep = require('../util/sleep'); 6 | 7 | const { argv, baseUrl, workerIndex } = workerData; 8 | 9 | let fileIndex = 1; 10 | let gStats = { requests: [], errors: 0 }; 11 | 12 | const cachedUrls = []; 13 | 14 | // worker main 15 | (async () => { 16 | 17 | // prime optimized original 18 | await request(`${baseUrl}/w:${workerIndex}/:/rs=h:1`).catch(() => null); 19 | let url; 20 | for (let i = 0; i < 10; i++) { 21 | url = `${baseUrl}/w:${workerIndex}/:/rs=w:${500+(i * 100)}/fm=f:${argv.format}`; 22 | cachedUrls.push(url); 23 | await request(url).catch(() => null); 24 | } 25 | 26 | parentPort.postMessage({ ready: true }); 27 | 28 | const statsTimer = setInterval(sendStats, 100); 29 | 30 | while (true) { 31 | await nextRequest(); 32 | } 33 | 34 | clearInterval(statsTimer); 35 | sendStats(); // final push 36 | 37 | })(); 38 | 39 | function sendStats() { 40 | parentPort.postMessage(gStats); 41 | gStats = { requests: [], errors: 0 }; 42 | } 43 | 44 | async function nextRequest() { 45 | // unique index to avoid collisions with previous tests, workers, and files 46 | // every hit needs to be an origin hit 47 | const url = cachedUrls[fileIndex++ % cachedUrls.length]; 48 | const res = await request(url).catch(err => ({ 49 | err 50 | })); 51 | 52 | if (res.err) { 53 | gStats.errors++; 54 | await sleep(100); 55 | } else { 56 | gStats.requests.push(res); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/test/index.js: -------------------------------------------------------------------------------- 1 | module.exports = ['origin', 'optimized', 'cached', 'real-90', 'real-95']; 2 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/test/optimized.js: -------------------------------------------------------------------------------- 1 | const { 2 | parentPort, workerData 3 | } = require('worker_threads'); 4 | const request = require('../util/blind-request'); 5 | const sleep = require('../util/sleep'); 6 | 7 | const { argv, baseUrl, workerIndex } = workerData; 8 | 9 | let fileIndex = 1; 10 | let gStats = { requests: [], errors: 0 }; 11 | 12 | // worker main 13 | (async () => { 14 | 15 | // prime optimized original 16 | await request(`${baseUrl}/w:${workerIndex}/:/rs=h:1`).catch(() => null); 17 | 18 | parentPort.postMessage({ ready: true }); 19 | 20 | const statsTimer = setInterval(sendStats, 100); 21 | 22 | while (true) { 23 | await nextRequest(); 24 | } 25 | 26 | clearInterval(statsTimer); 27 | sendStats(); // final push 28 | 29 | })(); 30 | 31 | function sendStats() { 32 | parentPort.postMessage(gStats); 33 | gStats = { requests: [], errors: 0 }; 34 | } 35 | 36 | async function nextRequest() { 37 | // unique index to avoid collisions with previous tests, workers, and files 38 | // every hit needs to be an origin hit 39 | const url = `${baseUrl}/w:${workerIndex}/:/rs=w:${100+fileIndex++}/fm=f:${argv.format}`; 40 | const res = await request(url).catch(err => ({ 41 | err 42 | })); 43 | 44 | if (res.err) { 45 | gStats.errors++; 46 | await sleep(100); 47 | } else { 48 | gStats.requests.push(res); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/test/origin.js: -------------------------------------------------------------------------------- 1 | const { 2 | parentPort, workerData 3 | } = require('worker_threads'); 4 | const request = require('../util/blind-request'); 5 | const sleep = require('../util/sleep'); 6 | 7 | const { argv, baseUrl, workerIndex } = workerData; 8 | 9 | let fileIndex = 1; 10 | let gStats = { requests: [], errors: 0 }; 11 | 12 | // worker main 13 | (async () => { 14 | parentPort.postMessage({ ready: true }); 15 | 16 | const statsTimer = setInterval(sendStats, 100); 17 | 18 | while (true) { 19 | await nextRequest(); 20 | } 21 | 22 | clearInterval(statsTimer); 23 | sendStats(); // final push 24 | 25 | })(); 26 | 27 | function sendStats() { 28 | parentPort.postMessage(gStats); 29 | gStats = { requests: [], errors: 0 }; 30 | } 31 | 32 | async function nextRequest() { 33 | // unique index to avoid collisions with previous tests, workers, and files 34 | // every hit needs to be an origin hit 35 | const url = `${baseUrl}/w:${workerIndex}/f:${fileIndex++}/:/rs=w:1000/fm=f:${argv.format}`; 36 | const res = await request(url).catch(err => ({ 37 | err 38 | })); 39 | 40 | if (res.err) { 41 | gStats.errors++; 42 | await sleep(100); 43 | } else { 44 | gStats.requests.push(res); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/test/real-90.js: -------------------------------------------------------------------------------- 1 | const { 2 | parentPort, workerData 3 | } = require('worker_threads'); 4 | const request = require('../util/blind-request'); 5 | const sleep = require('../util/sleep'); 6 | 7 | const { argv, baseUrl, workerIndex } = workerData; 8 | 9 | const REAL_OPTIMIZED_MOD = 46; // Every 46th = 8% 10 | const REAL_ORIGIN_MOD = 49; // Every 49th = 2% 11 | 12 | let fileIndex = 1; 13 | let gStats = { requests: [], errors: 0 }; 14 | 15 | // worker main 16 | (async () => { 17 | 18 | // prime optimized original & cache artifact 19 | await request(`${baseUrl}/w:${workerIndex}/:/rs=w:1000/fm=f:${argv.format}`).catch(() => null); 20 | 21 | parentPort.postMessage({ ready: true }); 22 | 23 | const statsTimer = setInterval(sendStats, 100); 24 | 25 | while (true) { 26 | await nextRequest(); 27 | } 28 | 29 | clearInterval(statsTimer); 30 | sendStats(); // final push 31 | 32 | })(); 33 | 34 | function sendStats() { 35 | parentPort.postMessage(gStats); 36 | gStats = { requests: [], errors: 0 }; 37 | } 38 | 39 | async function nextRequest() { 40 | const url = (fileIndex % REAL_ORIGIN_MOD) === (REAL_ORIGIN_MOD-1) 41 | // always (unique) origin hit 42 | ? `${baseUrl}/w:${workerIndex}/f:${fileIndex}/:/rs=w:1000/fm=f:${argv.format}` : (fileIndex % REAL_OPTIMIZED_MOD) === (REAL_OPTIMIZED_MOD-1) 43 | // optimized url but will always generate new artifact 44 | ? `${baseUrl}/w:${workerIndex}/:/rs=w:${100+fileIndex}/fm=f:${argv.format}` 45 | // always cached 46 | : `${baseUrl}/w:${workerIndex}/:/rs=w:1000/fm=f:${argv.format}` 47 | 48 | const res = await request(url).catch(err => ({ 49 | err 50 | })); 51 | 52 | fileIndex++; 53 | 54 | if (res.err) { 55 | gStats.errors++; 56 | await sleep(100); 57 | } else { 58 | gStats.requests.push(res); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/test/real-95.js: -------------------------------------------------------------------------------- 1 | const { 2 | parentPort, workerData 3 | } = require('worker_threads'); 4 | const request = require('../util/blind-request'); 5 | const sleep = require('../util/sleep'); 6 | 7 | const { argv, baseUrl, workerIndex } = workerData; 8 | 9 | const REAL_OPTIMIZED_MOD = 48; // Every 48th = 4% 10 | const REAL_ORIGIN_MOD = 99; // Every 99th = 1% 11 | 12 | let fileIndex = 1; 13 | let gStats = { requests: [], errors: 0 }; 14 | 15 | // worker main 16 | (async () => { 17 | 18 | // prime optimized original & cache artifact 19 | await request(`${baseUrl}/w:${workerIndex}/:/rs=w:1000/fm=f:${argv.format}`).catch(() => null); 20 | 21 | parentPort.postMessage({ ready: true }); 22 | 23 | const statsTimer = setInterval(sendStats, 100); 24 | 25 | while (true) { 26 | await nextRequest(); 27 | } 28 | 29 | clearInterval(statsTimer); 30 | sendStats(); // final push 31 | 32 | })(); 33 | 34 | function sendStats() { 35 | parentPort.postMessage(gStats); 36 | gStats = { requests: [], errors: 0 }; 37 | } 38 | 39 | async function nextRequest() { 40 | const url = (fileIndex % REAL_ORIGIN_MOD) === (REAL_ORIGIN_MOD-1) 41 | // always (unique) origin hit 42 | ? `${baseUrl}/w:${workerIndex}/f:${fileIndex}/:/rs=w:1000/fm=f:${argv.format}` : (fileIndex % REAL_OPTIMIZED_MOD) === (REAL_OPTIMIZED_MOD-1) 43 | // optimized url but will always generate new artifact 44 | ? `${baseUrl}/w:${workerIndex}/:/rs=w:${100+fileIndex}/fm=f:${argv.format}` 45 | // always cached 46 | : `${baseUrl}/w:${workerIndex}/:/rs=w:1000/fm=f:${argv.format}` 47 | 48 | const res = await request(url).catch(err => ({ 49 | err 50 | })); 51 | 52 | fileIndex++; 53 | 54 | if (res.err) { 55 | gStats.errors++; 56 | await sleep(100); 57 | } else { 58 | gStats.requests.push(res); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/util/blind-request.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const https = require('https'); 3 | 4 | const agent = new http.Agent({ keepAlive: false, maxSockets: Infinity }); 5 | 6 | module.exports = (url, { rejectNon200 = true } = {}) => { 7 | 8 | return new Promise((resolve, reject) => { 9 | const start = Date.now(); 10 | const req = (/^https\:/.test(url) ? https : http).request(url, { 11 | agent, 12 | timeout: 20000 13 | }, res => { 14 | const ttfb = Date.now() - start; 15 | const { statusCode, headers } = res; 16 | 17 | if (rejectNon200 && statusCode >= 300) return void reject(new Error(`Unexpected status of ${statusCode} returned`)); 18 | 19 | const size = parseInt(headers['content-length']) || 0; 20 | 21 | res.resume(); 22 | 23 | resolve({ ttfb, size }); 24 | }); 25 | 26 | req.once('error', reject); 27 | req.once('timeout', reject); 28 | 29 | req.end(); 30 | }); 31 | 32 | } 33 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/util/sleep.js: -------------------------------------------------------------------------------- 1 | module.exports = async (timeout = 10000) => { 2 | return new Promise(resolve => { 3 | setTimeout(resolve, timeout).unref(); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /packages/image-steam-bench/src/verify-isteam.js: -------------------------------------------------------------------------------- 1 | const request = require('./util/blind-request'); 2 | const files = require('./files'); 3 | 4 | module.exports = async bench => { 5 | bench.log('Verifying image-steam can talk to image-steam-bench...'); 6 | 7 | let url; 8 | for (let i = 0; i < files.byIndex.length; i++) { 9 | url = `${bench.argv.url}/${files.byIndex[i]}`; 10 | bench.log(`Checking ${url}...`); 11 | await request(url); 12 | } 13 | 14 | bench.log('All checks passed.'); 15 | } 16 | -------------------------------------------------------------------------------- /scripts/dev.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | http: [{ 5 | port: 13337 6 | }], 7 | log: { 8 | warnings: true 9 | }, 10 | processor: { 11 | sharp: { 12 | cache: false, 13 | concurrency: 0, 14 | simd: true, 15 | defaults: { animated: true } 16 | } 17 | }, 18 | router: { 19 | steps: { 20 | fm: { 21 | name: 'format', 22 | f: 'format' 23 | } 24 | } 25 | }, 26 | storage: { 27 | defaults: { 28 | driver: 'fs', 29 | path: path.resolve(__dirname, '../test/files') 30 | }, 31 | cache: { 32 | path: path.resolve(__dirname, '../test/cache') 33 | }, 34 | cacheOptimized: { 35 | path: path.resolve(__dirname, '../test/cacheOptimized') 36 | }, 37 | cacheTTS: 600, 38 | cacheOptimizedTTS: 300, 39 | app: { 40 | proxy: { 41 | driver: 'http', 42 | isteamEndpoint: true, 43 | endpoint: 'http://localhost:13337' 44 | }, 45 | isteamb: { 46 | driver: 'isteamb' 47 | }, 48 | failApp: { 49 | driver: 'http', 50 | endpoint: 'https://badhost123123', 51 | fallback: 'fallbackApp' 52 | }, 53 | fallbackApp: { 54 | driver: 'fs', 55 | path: path.resolve(__dirname, '../test/files') 56 | }, 57 | A: { 58 | driver: 'fs', 59 | path: path.resolve(__dirname, '../test/files'), 60 | maxSize: { width: 8000, height: 8000 }, 61 | router: { 62 | originalSteps: { 63 | resize: { width: '8000', height: '8000' } 64 | }, 65 | } 66 | }, 67 | B: { 68 | driver: 'fs', 69 | path: path.resolve(__dirname, '../test/files'), 70 | maxSize: { width: 5120, height: 51200 }, 71 | }, 72 | }, 73 | replicas: { 74 | otherPlace: { 75 | cache: { 76 | path: path.resolve(__dirname, '../test/replica-cache') 77 | }, 78 | cacheOptimized: { 79 | path: path.resolve(__dirname, '../test/replica-cacheOptimized') 80 | } 81 | } 82 | } 83 | }, 84 | security: { 85 | enabled : false 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /scripts/invalid-sos.js: -------------------------------------------------------------------------------- 1 | const sharp = require('sharp'); 2 | const http = require('http'); 3 | 4 | const IMAGE_URL = 'http://blobby.wsimg.com/go/0296993c-cade-4ae8-8a65-b3172f22a3a0/6e14af04-904c-4654-936e-2bd7e6e08dd8.jpg'; 5 | 6 | function getImage() { 7 | return new Promise((resolve, reject) => { 8 | http.get(IMAGE_URL, res => { 9 | if (res.statusCode !== 200) return void reject(new Error(`Invalid response: ${res.statusCode}`)); 10 | 11 | const chunks = []; 12 | 13 | res.on('data', chunk => chunks.push(chunk)); 14 | res.on('end', () => { 15 | resolve(Buffer.concat(chunks)); 16 | }); 17 | }).on('error', reject); 18 | 19 | }); 20 | } 21 | 22 | (async () => { 23 | const imageData = await getImage(); 24 | 25 | const image = sharp(imageData); 26 | 27 | // the blow options won't matter, including output format.. anything will result in: 28 | // Error: VipsJpeg: Invalid SOS parameters for sequential JPEG 29 | // VipsJpeg: out of order read at line 0 30 | const saveData = await image 31 | .withMetadata() 32 | .resize(640, 480) 33 | .toFormat('jpeg') 34 | .toBuffer(); 35 | })(); 36 | -------------------------------------------------------------------------------- /scripts/launch-coverage-in-browser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | console.log("If you just ran unit tests, you don't have proper coverage."); 4 | 5 | start(); 6 | 7 | function start() { 8 | //only run this on windows/mac 9 | if (require('os').platform() === 'linux') return outputError(); 10 | 11 | var open = require('open'); 12 | var path = require('path'); 13 | 14 | var page = path.resolve(__dirname, '../coverage/lcov-report/index.html').replace(/\\/g, '/'); 15 | page = "file:///" + page; 16 | page = page.replace('file:////','file:///'); 17 | 18 | console.log("Opening coverage report in browser.\n"); 19 | open(page); 20 | setTimeout(outputError, 500); 21 | } 22 | 23 | function outputError() { 24 | console.error("If you just ran tests, you don't have full coverage..."); 25 | process.exit(666); //be evil, will break build 26 | } -------------------------------------------------------------------------------- /scripts/launch-demo-in-browser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | start(); 4 | 5 | function start() { 6 | //only run this on windows/mac 7 | if (require('os').platform() === 'linux') return; 8 | 9 | var open = require('open'); 10 | 11 | console.log("Opening coverage report in browser.\n"); 12 | open('http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/cr=l:50,t:50,w:-100,h:-100?cache=false'); 13 | } 14 | -------------------------------------------------------------------------------- /scripts/server.js: -------------------------------------------------------------------------------- 1 | var http = require('../').http; 2 | 3 | http.start(); 4 | 5 | process.argv.forEach(function(arg) { 6 | if (arg === '--isDemo') { 7 | require('./launch-demo-in-browser.js'); 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /test/.jshintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/.jshintignore -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "expr": true, 3 | "bitwise": false, 4 | "camelcase": true, 5 | "curly": false, 6 | "eqeqeq": true, 7 | "freeze": true, 8 | "immed": true, 9 | "indent": 2, 10 | "latedef": "nofunc", 11 | "laxbreak": true, 12 | "laxcomma": true, 13 | "newcap": true, 14 | "noarg": true, 15 | "node": true, 16 | "strict": true, 17 | "trailing": true, 18 | "undef": true, 19 | "unused": true, 20 | "globals": { 21 | "it": true, 22 | "describe": true, 23 | "before": true, 24 | "after": true, 25 | "beforeEach": true, 26 | "afterEach": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/commands.colors.tests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const chai = require('chai'); 5 | const expect = chai.expect; 6 | const sinon = require('sinon'); 7 | var sinonChai = require('sinon-chai'); 8 | chai.use(sinonChai); 9 | const fs = require('fs'); 10 | const lib = require('../lib/http/commands/colors'); 11 | 12 | const filesPath = path.resolve(__dirname, './files'); 13 | const steamEngineBuffer = fs.readFileSync( 14 | path.resolve(filesPath, 'steam-engine.jpg') 15 | ); 16 | 17 | describe('#Commands.colors', function () { 18 | let ret, command, image, reqInfo, req, res; 19 | 20 | beforeEach(function () { 21 | command = {}; 22 | image = { 23 | buffer: steamEngineBuffer, 24 | }; 25 | reqInfo = {}; 26 | req = {}; 27 | res = { 28 | writeHead: sinon.stub(), 29 | end: sinon.stub(), 30 | }; 31 | }); 32 | 33 | it('Default settings', function (cb) { 34 | ret = lib(command, image, reqInfo, req, res, (err, colors) => { 35 | expect(err).to.be.equal(null); 36 | expect(res.end).to.have.been.calledWithExactly( 37 | JSON.stringify({ colors: colors }) 38 | ); 39 | cb(); 40 | }); 41 | }); 42 | 43 | it('Fail if no srcBuffer', function (cb) { 44 | image.buffer = null; 45 | try { 46 | ret = lib(command, image, reqInfo, req, res, (err, colors) => { 47 | cb(new Error('Should not get this far')); 48 | }); 49 | } catch (ex) { 50 | expect(ex.message).to.be.equal( 51 | 'options.srcPath or options.srcBuffer is required' 52 | ); 53 | cb(); 54 | } 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/connect.tests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | var chai = require('chai'); 5 | var expect = chai.expect; 6 | var Connect = require('../lib/http/connect'); 7 | 8 | var filesPath = path.resolve(__dirname, './files'); 9 | 10 | describe('#Image Server Security', function () { 11 | var connect, connectOptions; 12 | 13 | before(function () { 14 | connectOptions = { 15 | stepTimeout: 1000, 16 | storage: { 17 | driver: 'fs', 18 | path: filesPath, 19 | }, 20 | }; 21 | 22 | connect = new Connect(connectOptions); 23 | }); 24 | 25 | after(function () {}); 26 | 27 | it('Default options.stepTimeout', function () { 28 | connect = new Connect(); // default 29 | expect(connect.options.stepTimeout).to.equal(60000); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/crop.tests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'); 4 | var expect = chai.expect; 5 | var crop = require('../lib/processor/steps/crop'); 6 | var sinon = require('sinon'); 7 | var sinonChai = require('sinon-chai'); 8 | 9 | chai.use(sinonChai); 10 | 11 | describe('#Crop step', function () { 12 | it('should correctly convert absolute percentage anchor positioning to pixel offsets', function () { 13 | var context = { 14 | sharp: { 15 | extract: sinon.spy(), 16 | }, 17 | processedImage: { 18 | info: { 19 | width: 1000, 20 | height: 500, 21 | }, 22 | }, 23 | }; 24 | var stepInfo = { 25 | anchorY: '70%', 26 | anchorX: '70%', 27 | width: 500, 28 | height: 250, 29 | }; 30 | crop(context, stepInfo); 31 | expect(context.sharp.extract).to.have.been.calledWith({ 32 | left: 450, 33 | top: 225, 34 | width: 500, 35 | height: 250, 36 | }); 37 | }); 38 | 39 | it('should correctly compute crop position when using absolute pixel anchor positioning', function () { 40 | var context = { 41 | sharp: { 42 | extract: sinon.spy(), 43 | }, 44 | processedImage: { 45 | info: { 46 | width: 1000, 47 | height: 500, 48 | }, 49 | }, 50 | }; 51 | var stepInfo = { 52 | anchorY: '300', 53 | anchorX: '750', 54 | width: 500, 55 | height: 250, 56 | }; 57 | crop(context, stepInfo); 58 | expect(context.sharp.extract).to.have.been.calledWith({ 59 | left: 500, 60 | top: 175, 61 | width: 500, 62 | height: 250, 63 | }); 64 | }); 65 | 66 | it("should correctly compute crop position when using absolute pixel anchor positioning with 'px'", function () { 67 | var context = { 68 | sharp: { 69 | extract: sinon.spy(), 70 | }, 71 | processedImage: { 72 | info: { 73 | width: 1000, 74 | height: 500, 75 | }, 76 | }, 77 | }; 78 | var stepInfo = { 79 | anchorY: '300px', 80 | anchorX: '750px', 81 | width: 500, 82 | height: 250, 83 | }; 84 | crop(context, stepInfo); 85 | expect(context.sharp.extract).to.have.been.calledWith({ 86 | left: 500, 87 | top: 175, 88 | width: 500, 89 | height: 250, 90 | }); 91 | }); 92 | 93 | it('should prevent negative top/left positioning when using absolute anchor position', function () { 94 | var context = { 95 | sharp: { 96 | extract: sinon.spy(), 97 | }, 98 | processedImage: { 99 | info: { 100 | width: 1000, 101 | height: 500, 102 | }, 103 | }, 104 | }; 105 | var stepInfo = { 106 | anchorY: '50', 107 | anchorX: '50', 108 | width: 500, 109 | height: 250, 110 | }; 111 | crop(context, stepInfo); 112 | expect(context.sharp.extract).to.have.been.calledWith({ 113 | left: 0, 114 | top: 0, 115 | width: 500, 116 | height: 250, 117 | }); 118 | }); 119 | 120 | it('should restrict crop region to the image bounds when using large anchor values', function () { 121 | var context = { 122 | sharp: { 123 | extract: sinon.spy(), 124 | }, 125 | processedImage: { 126 | info: { 127 | width: 1000, 128 | height: 500, 129 | }, 130 | }, 131 | }; 132 | var stepInfo = { 133 | anchorY: '450', 134 | anchorX: '900', 135 | width: 500, 136 | height: 250, 137 | }; 138 | crop(context, stepInfo); 139 | expect(context.sharp.extract).to.have.been.calledWith({ 140 | left: 500, 141 | top: 250, 142 | width: 500, 143 | height: 250, 144 | }); 145 | }); 146 | 147 | it('should shrink the crop region if it exceeds the image size', function () { 148 | var context = { 149 | sharp: { 150 | extract: sinon.spy(), 151 | }, 152 | processedImage: { 153 | info: { 154 | width: 1000, 155 | height: 500, 156 | }, 157 | }, 158 | }; 159 | var stepInfo = { 160 | anchorY: '450', 161 | anchorX: '900', 162 | width: 1100, 163 | height: 600, 164 | }; 165 | crop(context, stepInfo); 166 | expect(context.sharp.extract).to.have.been.calledWith({ 167 | left: 0, 168 | top: 0, 169 | width: 1000, 170 | height: 500, 171 | }); 172 | }); 173 | 174 | it('should correctly handle cropping when specifying an anchor quadrant', function () { 175 | var context = { 176 | sharp: { 177 | extract: sinon.spy(), 178 | }, 179 | processedImage: { 180 | info: { 181 | width: 1000, 182 | height: 500, 183 | }, 184 | }, 185 | }; 186 | var stepInfo = { 187 | anchor: 'br', 188 | width: 200, 189 | height: 100, 190 | }; 191 | crop(context, stepInfo); 192 | expect(context.sharp.extract).to.have.been.calledWith({ 193 | left: 800, 194 | top: 400, 195 | width: 200, 196 | height: 100, 197 | }); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /test/dimension.tests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'); 4 | var _ = require('lodash'); 5 | var expect = chai.expect; 6 | var dimension = require('../lib/helpers/dimension'); 7 | 8 | describe('#Dimension utilities', function () { 9 | describe('#getInfo', function () { 10 | it('should handle fractional percentage values', function () { 11 | expect(dimension.getInfo('1.5%')).to.deep.equal({ 12 | unit: '%', 13 | modifier: null, 14 | value: 1.5, 15 | }); 16 | expect(dimension.getInfo('+2.%')).to.deep.equal({ 17 | unit: '%', 18 | modifier: '+', 19 | value: 2, 20 | }); 21 | expect(dimension.getInfo('.25%')).to.deep.equal({ 22 | unit: '%', 23 | modifier: null, 24 | value: 0.25, 25 | }); 26 | expect(dimension.getInfo('0.5%')).to.deep.equal({ 27 | unit: '%', 28 | modifier: null, 29 | value: 0.5, 30 | }); 31 | expect(dimension.getInfo('-1.5%')).to.deep.equal({ 32 | unit: '%', 33 | modifier: '-', 34 | value: 1.5, 35 | }); 36 | }); 37 | 38 | it('should support only integer pixel values', function () { 39 | expect(dimension.getInfo('1.5px')).to.deep.equal({ 40 | unit: 'px', 41 | modifier: null, 42 | value: 1, 43 | }); 44 | expect(dimension.getInfo('0.5')).to.deep.equal({ 45 | unit: 'px', 46 | modifier: null, 47 | value: 0, 48 | }); 49 | }); 50 | }); 51 | 52 | describe('#resolveStep', function () { 53 | it('should correctly convert percentage top/left/width/height values to pixels', function () { 54 | var originalImage = { 55 | info: { 56 | width: 2000, 57 | height: 1000, 58 | }, 59 | }; 60 | var imageStep = { 61 | top: '10.5%', 62 | left: '50%', 63 | width: '22.5%', 64 | height: '0.5%', 65 | }; 66 | dimension.resolveStep(originalImage, imageStep); 67 | expect(imageStep).to.deep.equal({ 68 | top: 105, 69 | left: 1000, 70 | width: 450, 71 | height: 5, 72 | }); 73 | }); 74 | 75 | describe('with anchor position specified', function () { 76 | var originalImage = { 77 | info: { 78 | width: 2000, 79 | height: 1000, 80 | }, 81 | }; 82 | 83 | it('should handle percentage values', function () { 84 | var imageStep = { 85 | width: '200', 86 | height: '100', 87 | anchorX: '30%', 88 | anchorY: '70%', 89 | }; 90 | dimension.resolveStep(originalImage, imageStep); 91 | expect(imageStep).to.deep.equal({ 92 | width: 200, 93 | height: 100, 94 | anchorX: 600, 95 | anchorY: 700, 96 | }); 97 | }); 98 | 99 | it('should handle pixel values', function () { 100 | var imageStep = { 101 | width: '200', 102 | height: '100', 103 | anchorX: '300px', 104 | anchorY: '500px', 105 | }; 106 | dimension.resolveStep(originalImage, imageStep); 107 | expect(imageStep).to.deep.equal({ 108 | width: 200, 109 | height: 100, 110 | anchorX: 300, 111 | anchorY: 500, 112 | }); 113 | }); 114 | 115 | it('should handle positive percentage offset values with no anchor specified', function () { 116 | var imageStep = { 117 | width: '200', 118 | height: '100', 119 | anchorX: '+10%', 120 | anchorY: '+20%', 121 | }; 122 | dimension.resolveStep(originalImage, imageStep); 123 | expect(imageStep).to.deep.equal({ 124 | width: 200, 125 | height: 100, 126 | anchorX: 1200, 127 | anchorY: 700, 128 | }); 129 | }); 130 | 131 | it('should handle positive pixel offset values with no anchor specified', function () { 132 | var imageStep = { 133 | width: '200', 134 | height: '100', 135 | anchorX: '+100px', 136 | anchorY: '+200px', 137 | }; 138 | dimension.resolveStep(originalImage, imageStep); 139 | expect(imageStep).to.deep.equal({ 140 | width: 200, 141 | height: 100, 142 | anchorX: 1100, 143 | anchorY: 700, 144 | }); 145 | }); 146 | 147 | it('should handle negative percentage offset values with no anchor specified', function () { 148 | var imageStep = { 149 | width: '200', 150 | height: '100', 151 | anchorX: '-10%', 152 | anchorY: '-25%', 153 | }; 154 | dimension.resolveStep(originalImage, imageStep); 155 | expect(imageStep).to.deep.equal({ 156 | width: 200, 157 | height: 100, 158 | anchorX: 800, 159 | anchorY: 250, 160 | }); 161 | }); 162 | 163 | it('should handle negative pixel offset values with no anchor specified', function () { 164 | var imageStep = { 165 | width: '200', 166 | height: '100', 167 | anchorX: '-100px', 168 | anchorY: '-200px', 169 | }; 170 | dimension.resolveStep(originalImage, imageStep); 171 | expect(imageStep).to.deep.equal({ 172 | width: 200, 173 | height: 100, 174 | anchorX: 900, 175 | anchorY: 300, 176 | }); 177 | }); 178 | 179 | it('should handle offset values with anchor specified', function () { 180 | var imageStep, 181 | defaults = { 182 | width: '200', 183 | height: '100', 184 | anchorX: '+10%', 185 | anchorY: '+25%', 186 | }; 187 | imageStep = _.assign({}, defaults, { anchor: 'bl', anchorY: '-25%' }); 188 | dimension.resolveStep(originalImage, imageStep); 189 | expect(imageStep).to.deep.equal({ 190 | width: 200, 191 | height: 100, 192 | anchorX: 300, 193 | anchorY: 700, 194 | anchor: 'bl', 195 | }); 196 | imageStep = _.assign({}, defaults, { anchor: 'tr', anchorX: '-10%' }); 197 | dimension.resolveStep(originalImage, imageStep); 198 | expect(imageStep).to.deep.equal({ 199 | width: 200, 200 | height: 100, 201 | anchorX: 1700, 202 | anchorY: 300, 203 | anchor: 'tr', 204 | }); 205 | imageStep = _.assign({}, defaults, { anchor: 'bc', anchorY: '-25%' }); 206 | dimension.resolveStep(originalImage, imageStep); 207 | expect(imageStep).to.deep.equal({ 208 | width: 200, 209 | height: 100, 210 | anchorX: 1200, 211 | anchorY: 700, 212 | anchor: 'bc', 213 | }); 214 | imageStep = _.assign({}, defaults, { anchor: 'cr', anchorX: '-400px' }); 215 | dimension.resolveStep(originalImage, imageStep); 216 | expect(imageStep).to.deep.equal({ 217 | width: 200, 218 | height: 100, 219 | anchorX: 1500, 220 | anchorY: 750, 221 | anchor: 'cr', 222 | }); 223 | }); 224 | }); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /test/files/A/UP_steam_loco.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/A/UP_steam_loco.jpg -------------------------------------------------------------------------------- /test/files/A/big.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/A/big.jpeg -------------------------------------------------------------------------------- /test/files/B/UP_steam_loco.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/B/UP_steam_loco.jpg -------------------------------------------------------------------------------- /test/files/B/big.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/B/big.jpeg -------------------------------------------------------------------------------- /test/files/Eiffel_Tower_Vertical.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/Eiffel_Tower_Vertical.JPG -------------------------------------------------------------------------------- /test/files/FM134-3青绿%20(速卖通不上)%20(4).jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/FM134-3青绿%20(速卖通不上)%20(4).jpg -------------------------------------------------------------------------------- /test/files/Portrait_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/Portrait_1.jpg -------------------------------------------------------------------------------- /test/files/Portrait_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/Portrait_2.jpg -------------------------------------------------------------------------------- /test/files/Portrait_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/Portrait_3.jpg -------------------------------------------------------------------------------- /test/files/Portrait_4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/Portrait_4.jpg -------------------------------------------------------------------------------- /test/files/Portrait_5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/Portrait_5.jpg -------------------------------------------------------------------------------- /test/files/Portrait_6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/Portrait_6.jpg -------------------------------------------------------------------------------- /test/files/Portrait_7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/Portrait_7.jpg -------------------------------------------------------------------------------- /test/files/Portrait_8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/Portrait_8.jpg -------------------------------------------------------------------------------- /test/files/UP_steam_loco.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/UP_steam_loco.jpg -------------------------------------------------------------------------------- /test/files/animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/animated.gif -------------------------------------------------------------------------------- /test/files/big.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/big.jpeg -------------------------------------------------------------------------------- /test/files/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Shape 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/files/core-dump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/core-dump.png -------------------------------------------------------------------------------- /test/files/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/favicon.png -------------------------------------------------------------------------------- /test/files/gaines.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/gaines.jpg -------------------------------------------------------------------------------- /test/files/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/icon.ico -------------------------------------------------------------------------------- /test/files/invalid-sos.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/invalid-sos.jpg -------------------------------------------------------------------------------- /test/files/isteam-arch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/isteam-arch.jpg -------------------------------------------------------------------------------- /test/files/leds.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/leds.webp -------------------------------------------------------------------------------- /test/files/marbles-0004.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/marbles-0004.tif -------------------------------------------------------------------------------- /test/files/next.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/next.jpg -------------------------------------------------------------------------------- /test/files/next2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/next2.jpg -------------------------------------------------------------------------------- /test/files/not-an-image.jpg: -------------------------------------------------------------------------------- 1 | BAD, BAD IMAGE! 2 | -------------------------------------------------------------------------------- /test/files/roast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/roast.png -------------------------------------------------------------------------------- /test/files/spider8k.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/spider8k.png -------------------------------------------------------------------------------- /test/files/steam-engine-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/steam-engine-2.jpg -------------------------------------------------------------------------------- /test/files/steam-engine.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/steam-engine.jpg -------------------------------------------------------------------------------- /test/files/steam-engine11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/steam-engine11.png -------------------------------------------------------------------------------- /test/files/test.css: -------------------------------------------------------------------------------- 1 | .myclass { 2 | } 3 | -------------------------------------------------------------------------------- /test/files/test.eot: -------------------------------------------------------------------------------- 1 | eot -------------------------------------------------------------------------------- /test/files/test.js: -------------------------------------------------------------------------------- 1 | console.log('hello world'); 2 | -------------------------------------------------------------------------------- /test/files/test.otf: -------------------------------------------------------------------------------- 1 | otf -------------------------------------------------------------------------------- /test/files/test.ttf: -------------------------------------------------------------------------------- 1 | ttf -------------------------------------------------------------------------------- /test/files/test.txt: -------------------------------------------------------------------------------- 1 | This is a text file. 2 | -------------------------------------------------------------------------------- /test/files/test.woff: -------------------------------------------------------------------------------- 1 | woff -------------------------------------------------------------------------------- /test/files/test.woff2: -------------------------------------------------------------------------------- 1 | woff2 -------------------------------------------------------------------------------- /test/files/tower-error.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/tower-error.jpeg -------------------------------------------------------------------------------- /test/files/webp-too-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asilvas/node-image-steam/8e8ddbfa544f5ec9ccfbd37e28666fd61a5cc63b/test/files/webp-too-large.png -------------------------------------------------------------------------------- /test/image-server.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | storage: { 5 | defaults: { 6 | driver: 'fs', 7 | path: path.resolve(__dirname, './files'), 8 | }, 9 | cache: { 10 | path: path.resolve(__dirname, '../test/cache'), 11 | }, 12 | cacheOptimized: { 13 | path: path.resolve(__dirname, '../test/cacheOptimized'), 14 | }, 15 | cacheTTS: 600, 16 | cacheOptimizedTTS: 300, 17 | replicas: { 18 | otherPlace: { 19 | cache: { 20 | path: path.resolve(__dirname, '../test/replica-cache'), 21 | }, 22 | cacheOptimized: { 23 | path: path.resolve(__dirname, '../test/replica-cacheOptimized'), 24 | }, 25 | }, 26 | }, 27 | }, 28 | router: { 29 | beforeProcess: function (routeInfo, options) { 30 | if (!routeInfo.urlInfo.pathname) { 31 | return; 32 | } 33 | 34 | const gisRegex = /gis(\=([w,h,s])([0-9]{1,4})){0,1}/; 35 | const match = gisRegex.exec(routeInfo.urlInfo.pathname); 36 | 37 | if (match) { 38 | let replacement; 39 | if (match[3] && match[3] !== '0') { 40 | replacement = `rs${options.cmdValDelimiter}`; 41 | switch (match[2]) { 42 | case 's': 43 | replacement += `w${options.paramValDelimiter}${match[3]}${options.paramKeyDelimiter}h${options.paramValDelimiter}${match[3]}`; 44 | break; 45 | case 'w': 46 | replacement += `w${options.paramValDelimiter}${match[3]}`; 47 | break; 48 | case 'h': 49 | replacement += `h${options.paramValDelimiter}${match[3]}`; 50 | break; 51 | default: 52 | throw new Error( 53 | 'Unsupported param ' + 54 | match[1] + 55 | ' while parsing google image service command ' + 56 | match[0] 57 | ); 58 | } 59 | } else { 60 | // when /:/gis=s0, /:/gis, we need to drop gis from the path. 61 | replacement = ``; 62 | } 63 | routeInfo.urlInfo.pathname = routeInfo.urlInfo.pathname.replace( 64 | match[0], 65 | replacement 66 | ); 67 | } 68 | }, 69 | steps: { 70 | fm: { 71 | name: 'format', 72 | f: 'format', 73 | }, 74 | }, 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /test/image-server.etags.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "DELETE http://localhost:13337/UP_steam_loco.jpg/:/fm=f:raw?cache=false" 4 | }, 5 | { 6 | "url": "GET http://localhost:13337/UP_steam_loco.jpg/:/gis=h400/fm=f:raw?cache=false", 7 | "etag": "4192721505" 8 | }, 9 | { 10 | "url": "GET http://localhost:13337/UP_steam_loco.jpg/:/gis=s0?cache=false", 11 | "etag": "3401005584" 12 | }, 13 | { 14 | "url": "GET http://localhost:13337/UP_steam_loco.jpg/:/gis=s0?cache=true", 15 | "etag": "422219860" 16 | }, 17 | { 18 | "url": "GET http://localhost:13337/UP_steam_loco.jpg/:/gis=s400/fm=f:raw?cache=false", 19 | "etag": "801286402" 20 | }, 21 | { 22 | "url": "GET http://localhost:13337/UP_steam_loco.jpg/:/gis=w400/fm=f:raw?cache=false", 23 | "etag": "801286402" 24 | }, 25 | { 26 | "url": "GET http://localhost:13337/UP_steam_loco.jpg/:/gis?cache=false", 27 | "etag": "3401005584" 28 | }, 29 | { 30 | "url": "GET http://localhost:13337/UP_steam_loco.jpg/:/gis?cache=true", 31 | "etag": "422219860" 32 | }, 33 | { 34 | "url": "http://localhost:13337/Portrait_1.jpg/:/?cache=false", 35 | "etag": "2876286190" 36 | }, 37 | { 38 | "url": "http://localhost:13337/Portrait_1.jpg/:/rt=d:180?cache=false", 39 | "etag": "3262873131" 40 | }, 41 | { 42 | "url": "http://localhost:13337/Portrait_1.jpg/:/rt=d:270?cache=false", 43 | "etag": "4172073250" 44 | }, 45 | { 46 | "url": "http://localhost:13337/Portrait_1.jpg/:/rt=d:90?cache=false", 47 | "etag": "98866249" 48 | }, 49 | { 50 | "url": "http://localhost:13337/Portrait_2.jpg/:/?cache=false", 51 | "etag": "621314706" 52 | }, 53 | { 54 | "url": "http://localhost:13337/Portrait_2.jpg/:/rs=w:320?cache=false", 55 | "etag": "2427400978" 56 | }, 57 | { 58 | "url": "http://localhost:13337/Portrait_2.jpg/:/rt=d:180?cache=false", 59 | "etag": "1716375556" 60 | }, 61 | { 62 | "url": "http://localhost:13337/Portrait_2.jpg/:/rt=d:270?cache=false", 63 | "etag": "95989407" 64 | }, 65 | { 66 | "url": "http://localhost:13337/Portrait_2.jpg/:/rt=d:90?cache=false", 67 | "etag": "3701879581" 68 | }, 69 | { 70 | "url": "http://localhost:13337/Portrait_3.jpg/:/?cache=false", 71 | "etag": "3220232200" 72 | }, 73 | { 74 | "url": "http://localhost:13337/Portrait_3.jpg/:/rs=w:320?cache=false", 75 | "etag": "581170845" 76 | }, 77 | { 78 | "url": "http://localhost:13337/Portrait_3.jpg/:/rt=d:180?cache=false", 79 | "etag": "1724899874" 80 | }, 81 | { 82 | "url": "http://localhost:13337/Portrait_3.jpg/:/rt=d:270?cache=false", 83 | "etag": "274221414" 84 | }, 85 | { 86 | "url": "http://localhost:13337/Portrait_3.jpg/:/rt=d:90?cache=false", 87 | "etag": "2191119633" 88 | }, 89 | { 90 | "url": "http://localhost:13337/Portrait_4.jpg/:/?cache=false", 91 | "etag": "2774001998" 92 | }, 93 | { 94 | "url": "http://localhost:13337/Portrait_4.jpg/:/rs=w:320?cache=false", 95 | "etag": "1180735760" 96 | }, 97 | { 98 | "url": "http://localhost:13337/Portrait_4.jpg/:/rt=d:180?cache=false", 99 | "etag": "3626527791" 100 | }, 101 | { 102 | "url": "http://localhost:13337/Portrait_4.jpg/:/rt=d:270?cache=false", 103 | "etag": "1438662276" 104 | }, 105 | { 106 | "url": "http://localhost:13337/Portrait_4.jpg/:/rt=d:90?cache=false", 107 | "etag": "1296269542" 108 | }, 109 | { 110 | "url": "http://localhost:13337/Portrait_5.jpg/:/?cache=false", 111 | "etag": "1745037" 112 | }, 113 | { 114 | "url": "http://localhost:13337/Portrait_5.jpg/:/rs=w:320?cache=false", 115 | "etag": "36419165" 116 | }, 117 | { 118 | "url": "http://localhost:13337/Portrait_5.jpg/:/rt=d:180?cache=false", 119 | "etag": "941668244" 120 | }, 121 | { 122 | "url": "http://localhost:13337/Portrait_5.jpg/:/rt=d:270?cache=false", 123 | "etag": "4031353629" 124 | }, 125 | { 126 | "url": "http://localhost:13337/Portrait_5.jpg/:/rt=d:90?cache=false", 127 | "etag": "33231874" 128 | }, 129 | { 130 | "url": "http://localhost:13337/Portrait_6.jpg/:/?cache=false", 131 | "etag": "1588168154" 132 | }, 133 | { 134 | "url": "http://localhost:13337/Portrait_6.jpg/:/rs=w:320?cache=false", 135 | "etag": "2155865972" 136 | }, 137 | { 138 | "url": "http://localhost:13337/Portrait_6.jpg/:/rt=d:180?cache=false", 139 | "etag": "806439467" 140 | }, 141 | { 142 | "url": "http://localhost:13337/Portrait_6.jpg/:/rt=d:270?cache=false", 143 | "etag": "2759654619" 144 | }, 145 | { 146 | "url": "http://localhost:13337/Portrait_6.jpg/:/rt=d:90?cache=false", 147 | "etag": "254804682" 148 | }, 149 | { 150 | "url": "http://localhost:13337/Portrait_7.jpg/:/?cache=false", 151 | "etag": "2560695817" 152 | }, 153 | { 154 | "url": "http://localhost:13337/Portrait_7.jpg/:/rs=w:320?cache=false", 155 | "etag": "3444163917" 156 | }, 157 | { 158 | "url": "http://localhost:13337/Portrait_7.jpg/:/rt=d:180?cache=false", 159 | "etag": "4156156995" 160 | }, 161 | { 162 | "url": "http://localhost:13337/Portrait_7.jpg/:/rt=d:270?cache=false", 163 | "etag": "161157245" 164 | }, 165 | { 166 | "url": "http://localhost:13337/Portrait_7.jpg/:/rt=d:90?cache=false", 167 | "etag": "3822393549" 168 | }, 169 | { 170 | "url": "http://localhost:13337/Portrait_8.jpg/:/?cache=false", 171 | "etag": "1577596038" 172 | }, 173 | { 174 | "url": "http://localhost:13337/Portrait_8.jpg/:/rs=w:320?cache=false", 175 | "etag": "2921791791" 176 | }, 177 | { 178 | "url": "http://localhost:13337/Portrait_8.jpg/:/rt=d:180?cache=false", 179 | "etag": "1362729998" 180 | }, 181 | { 182 | "url": "http://localhost:13337/Portrait_8.jpg/:/rt=d:270?cache=false", 183 | "etag": "1606490333" 184 | }, 185 | { 186 | "url": "http://localhost:13337/Portrait_8.jpg/:/rt=d:90?cache=false", 187 | "etag": "2052419191" 188 | }, 189 | { 190 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/$colors=mn:false?cache=false" 191 | }, 192 | { 193 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/$colors?cache=false" 194 | }, 195 | { 196 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/?useOriginal=true&cache=false" 197 | }, 198 | { 199 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/cp=c:1/fm=f:png?cache=false", 200 | "etag": "4015922986" 201 | }, 202 | { 203 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/cp=c:2/pg/fm=f:png?cache=false", 204 | "etag": "2861341614" 205 | }, 206 | { 207 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/cr=l:5%25,t:10%25,w:90%25,h:80%25/fm=f:raw?cache=false", 208 | "etag": "2773815917" 209 | }, 210 | { 211 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/cr=l:50,t:50,w:-100,h:-100/fm=f:raw?cache=false", 212 | "etag": "1128897476" 213 | }, 214 | { 215 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/cr=l:50,t:50/fm=f:raw?cache=false", 216 | "etag": "3507553029" 217 | }, 218 | { 219 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/cr=w:50%25,h:50%25,a:br/fm=f:raw?cache=false", 220 | "etag": "225979428" 221 | }, 222 | { 223 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/cr=w:50%25,h:50%25,a:tl/fm=f:raw?cache=false", 224 | "etag": "42824899" 225 | }, 226 | { 227 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/cr=w:50%25,h:50%25,ay:500,ax:300/fm=f:raw?cache=false", 228 | "etag": "2125820921" 229 | }, 230 | { 231 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/fl=x,y/fm=f:raw?cache=false", 232 | "etag": "3027513620" 233 | }, 234 | { 235 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/fl=x/fm=f:raw?cache=false", 236 | "etag": "944833281" 237 | }, 238 | { 239 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/fl=y/fm=f:raw?cache=false", 240 | "etag": "3791216710" 241 | }, 242 | { 243 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/fm=f:avif?cache=false", 244 | "etag": "692844096" 245 | }, 246 | { 247 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/fm=f:raw?cache=false", 248 | "etag": "1736867771" 249 | }, 250 | { 251 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/fm=f:webp?cache=false", 252 | "etag": "1310556652" 253 | }, 254 | { 255 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/fx-bl/fm=f:raw?cache=false", 256 | "etag": "4250052166" 257 | }, 258 | { 259 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/fx-bl=s:5/fm=f:raw?cache=false", 260 | "etag": "2040872983" 261 | }, 262 | { 263 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/gm=g:1/fm=f:raw?cache=false", 264 | "etag": "1736867771" 265 | }, 266 | { 267 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/gm=g:3/fm=f:raw?cache=false", 268 | "etag": "2315806164" 269 | }, 270 | { 271 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/md=e:false/fm=f:raw?cache=false", 272 | "etag": "1736867771" 273 | }, 274 | { 275 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/md=e:true/fm=f:raw?cache=false", 276 | "etag": "1736867771" 277 | }, 278 | { 279 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/qt=q:20/fm=f:raw?cache=false", 280 | "etag": "1736867771" 281 | }, 282 | { 283 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:128,h:128,m/cr=w:128,h:128/fx-gs/fm=f:raw?cache=false", 284 | "etag": "1047043934" 285 | }, 286 | { 287 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:320/exd=l:10%25,t:10%25,r:10%25,b:10%25,bg:hex(aaaaaa)/fm=f:raw?cache=false", 288 | "etag": "977977686" 289 | }, 290 | { 291 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:320/exd=l:50,t:50,r:50,b:50,bg:rgba(80;60;120;0.5)/fm=f:raw?cache=false", 292 | "etag": "3797522506" 293 | }, 294 | { 295 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/$badCommand/fm=f:raw?cache=false" 296 | }, 297 | { 298 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/cr=t:-10%25,l:-10%25,w:-20%25,h:-20%25/fm=f:raw?cache=false", 299 | "etag": "4155357765" 300 | }, 301 | { 302 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/cr=t:-10,l:-10,w:-20,h:-20/fm=f:raw?cache=false", 303 | "etag": "3264320233" 304 | }, 305 | { 306 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/fm=f:raw?cache=false", 307 | "etag": "1430076433" 308 | }, 309 | { 310 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/fm=f:raw?cache=true", 311 | "etag": "1093979100" 312 | }, 313 | { 314 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/fm=f:raw?download=true&cache=false", 315 | "etag": "1430076433" 316 | }, 317 | { 318 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/fx-sp=r:5/fm=f:raw?cache=false", 319 | "etag": "4168896103" 320 | }, 321 | { 322 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/ip/fm=f:raw?cache=false", 323 | "etag": "1430076433" 324 | }, 325 | { 326 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/ip=i:bicubic/fm=f:raw?cache=false", 327 | "etag": "1430076433" 328 | }, 329 | { 330 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/ip=i:bilinear/fm=f:raw?cache=false", 331 | "etag": "1430076433" 332 | }, 333 | { 334 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rs=w:640/ip=i:nearest/fm=f:raw?cache=false", 335 | "etag": "1430076433" 336 | }, 337 | { 338 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rt=d:180/fm=f:raw?cache=false", 339 | "etag": "3027513620" 340 | }, 341 | { 342 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rt=d:270/fm=f:raw?cache=false", 343 | "etag": "109238267" 344 | }, 345 | { 346 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rt=d:360/fm=f:raw?cache=false", 347 | "etag": "1736867771" 348 | }, 349 | { 350 | "url": "http://localhost:13337/UP_steam_loco.jpg/:/rt=d:90/fm=f:raw?cache=false", 351 | "etag": "2393051910" 352 | }, 353 | { 354 | "url": "http://localhost:13337/roast.png/:/?cache=false", 355 | "etag": "2932124927" 356 | }, 357 | { 358 | "url": "http://localhost:13337/steam-engine.jpg/:/rs=w:640?cache=false", 359 | "etag": "397964491" 360 | }, 361 | { 362 | "url": "http://localhost:13337/steam-engine11.png/:/rs=w:640?cache=false", 363 | "etag": "548539915" 364 | } 365 | ] -------------------------------------------------------------------------------- /test/image-server.requests.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | steps: '', 4 | label: 'clear cache', 5 | options: { method: 'DELETE', statusCode: 204 }, 6 | }, 7 | { steps: 'rs=w:640', label: 'resize max to 640 width' }, 8 | { 9 | steps: '', 10 | label: 'use original', 11 | qs: { useOriginal: 'true' }, 12 | options: { disableFormat: true }, 13 | }, 14 | { 15 | steps: '$colors', 16 | label: 'get color palette', 17 | options: { disableFormat: true }, 18 | }, 19 | { 20 | steps: 'rs=w:640/$badCommand', 21 | label: 'command not found', 22 | options: { statusCode: 400, security: false }, 23 | }, 24 | { 25 | steps: '$colors=mn:false', 26 | label: 'get color palette with mean disabled', 27 | options: { disableFormat: true }, 28 | }, 29 | { steps: 'rs=w:640', label: 'cache on', qs: { cache: 'true' } }, 30 | { steps: 'rs=w:640', label: 'download', qs: { download: 'true' } }, 31 | { 32 | steps: 'gis=s400', 33 | label: 34 | 'use google image service param to indicate that the length on the longer side is 400', 35 | options: { method: 'GET', statusCode: 200 }, 36 | }, 37 | { 38 | steps: 'gis=w400', 39 | label: 'use google image service param to indicate that the width is 400', 40 | options: { method: 'GET', statusCode: 200 }, 41 | }, 42 | { 43 | steps: 'gis=h400', 44 | label: 'use google image service param to indicate that the height is 400', 45 | options: { method: 'GET', statusCode: 200 }, 46 | }, 47 | { 48 | steps: 'gis=s0', 49 | label: 'use google image service param to indicate to return default image', 50 | options: { method: 'GET', statusCode: 200, disableFormat: true }, 51 | qs: { cache: 'true' }, 52 | }, 53 | { 54 | steps: 'gis', 55 | label: 'Return default image if only gis is specified', 56 | options: { method: 'GET', statusCode: 200, disableFormat: true }, 57 | qs: { cache: 'true' }, 58 | }, 59 | { 60 | steps: 'rs=w:640/cr=t:-10,l:-10,w:-20,h:-20', 61 | label: 'top/left/width/height -10px', 62 | }, 63 | { 64 | steps: 'rs=w:640/cr=t:-10%25,l:-10%25,w:-20%25,h:-20%25', 65 | label: 'top/left/width/height -10%', 66 | }, 67 | { 68 | steps: 'rs=w:640', 69 | imageName: 'steam-engine.jpg', 70 | label: 'resize max to 640 width on alternate image', 71 | }, 72 | { steps: '', imageName: 'roast.png', label: 'test HQ settings' }, 73 | { 74 | steps: 'rs=w:320/exd=l:10%25,t:10%25,r:10%25,b:10%25,bg:hex(aaaaaa)', 75 | label: 'extend 10% around all edges with hex', 76 | }, 77 | { 78 | steps: 'rs=w:320/exd=l:50,t:50,r:50,b:50,bg:rgba(80;60;120;0.5)', 79 | label: 'extend 10% around all edges with rgba', 80 | }, 81 | // github does not (yet) support these special characters, so opting to skip this for now -- verified locally 82 | // { steps: 'rs=w:640', imageName: 'file !"$%25&\'()*+,-.jpg', label: 'Verify filename encoding is supported' }, 83 | { steps: 'rs=w:640/fx-sp=r:5', label: 'resize & apply sharpening' }, 84 | { 85 | steps: 'rs=w:128,h:128,m/cr=w:128,h:128/fx-gs', 86 | label: 'create thumbnail and apply greyscale', 87 | }, 88 | { steps: 'gm=g:1', label: 'apply min gamma' }, 89 | { steps: 'gm=g:3', label: 'apply max gamma' }, 90 | { 91 | steps: 'cr=l:5%25,t:10%25,w:90%25,h:80%25', 92 | label: 'crop using percentages', 93 | }, 94 | { steps: 'cr=l:50,t:50,w:-100,h:-100', label: 'crop with offsets' }, 95 | { 96 | steps: 'cr=l:50,t:50', 97 | label: 'crop left and top using default width/height', 98 | }, 99 | { 100 | steps: 'cr=w:50%25,h:50%25,a:tl', 101 | label: 'crop to 50% anchored from top/left', 102 | }, 103 | { 104 | steps: 'cr=w:50%25,h:50%25,a:br', 105 | label: 'crop to 50% anchored from bottom/right', 106 | }, 107 | { 108 | steps: 'cr=w:50%25,h:50%25,ay:500,ax:300', 109 | label: 'crop to 50% with pixel anchor positioning', 110 | }, 111 | { steps: 'qt=q:20', label: 'apply low quality' }, 112 | { 113 | steps: 'cp=c:1/fm=f:png', 114 | label: 'output png and use compression', 115 | contentType: 'image/png', 116 | }, 117 | { 118 | steps: 'cp=c:2/pg/fm=f:png', 119 | label: 'output png with compression and progressive rendering', 120 | contentType: 'image/png', 121 | }, 122 | { steps: 'fm=f:webp', label: 'use webp format', contentType: 'image/webp' }, 123 | { steps: 'rt=d:90', label: 'rotate 90 degrees' }, 124 | { steps: 'rt=d:180', label: 'rotate 180 degrees' }, 125 | { steps: 'rt=d:270', label: 'rotate 270 degrees' }, 126 | { steps: 'rt=d:360', label: 'rotate 360 degrees' }, 127 | { steps: 'fl=x', label: 'flip on x axis' }, 128 | { steps: 'fl=y', label: 'flip on y axis' }, 129 | { steps: 'fl=x,y', label: 'flip on x & y axis' }, 130 | { steps: 'md=e:false', label: 'metadata disabled' }, 131 | { steps: 'md=e:true', label: 'metadata enabled' }, 132 | { steps: '', label: 'metadata default to enabled' }, 133 | { 134 | steps: 'rs=w:640/ip=i:nearest', 135 | label: 'resize using nearest interpolation', 136 | }, 137 | { 138 | steps: 'rs=w:640/ip=i:bilinear', 139 | label: 'resize using bilinear interpolation', 140 | }, 141 | { steps: 'rs=w:640/ip', label: 'resize using default interpolation' }, 142 | { 143 | steps: 'rs=w:640/ip=i:bicubic', 144 | label: 'resize using bicubic interpolation', 145 | }, 146 | { 147 | steps: 'rs=w:640', 148 | imageName: 'steam-engine11.png', 149 | label: 'resize transparent image and retain alpha channel', 150 | }, 151 | // { steps: 'fx-nm', label: 'normalize' }, 152 | { steps: 'fx-bl', label: 'default blur' }, 153 | { steps: '', imageName: 'Portrait_1.jpg', label: 'Verify Orientation 1' }, 154 | { 155 | steps: 'rt=d:90', 156 | imageName: 'Portrait_1.jpg', 157 | label: 'Verify Orientation 1 @ 90 degrees', 158 | }, 159 | { 160 | steps: 'rt=d:180', 161 | imageName: 'Portrait_1.jpg', 162 | label: 'Verify Orientation 1 @ 180 degrees', 163 | }, 164 | { 165 | steps: 'rt=d:270', 166 | imageName: 'Portrait_1.jpg', 167 | label: 'Verify Orientation 1 @ 270 degrees', 168 | }, 169 | { steps: '', imageName: 'Portrait_2.jpg', label: 'Verify Orientation 2' }, 170 | { 171 | steps: 'rt=d:90', 172 | imageName: 'Portrait_2.jpg', 173 | label: 'Verify Orientation 2 @ 90 degrees', 174 | }, 175 | { 176 | steps: 'rt=d:180', 177 | imageName: 'Portrait_2.jpg', 178 | label: 'Verify Orientation 2 @ 180 degrees', 179 | }, 180 | { 181 | steps: 'rt=d:270', 182 | imageName: 'Portrait_2.jpg', 183 | label: 'Verify Orientation 2 @ 270 degrees', 184 | }, 185 | { steps: '', imageName: 'Portrait_3.jpg', label: 'Verify Orientation 3' }, 186 | { 187 | steps: 'rt=d:90', 188 | imageName: 'Portrait_3.jpg', 189 | label: 'Verify Orientation 3 @ 90 degrees', 190 | }, 191 | { 192 | steps: 'rt=d:180', 193 | imageName: 'Portrait_3.jpg', 194 | label: 'Verify Orientation 3 @ 180 degrees', 195 | }, 196 | { 197 | steps: 'rt=d:270', 198 | imageName: 'Portrait_3.jpg', 199 | label: 'Verify Orientation 3 @ 270 degrees', 200 | }, 201 | { steps: '', imageName: 'Portrait_4.jpg', label: 'Verify Orientation 4' }, 202 | { 203 | steps: 'rt=d:90', 204 | imageName: 'Portrait_4.jpg', 205 | label: 'Verify Orientation 4 @ 90 degrees', 206 | }, 207 | { 208 | steps: 'rt=d:180', 209 | imageName: 'Portrait_4.jpg', 210 | label: 'Verify Orientation 4 @ 180 degrees', 211 | }, 212 | { 213 | steps: 'rt=d:270', 214 | imageName: 'Portrait_4.jpg', 215 | label: 'Verify Orientation 4 @ 270 degrees', 216 | }, 217 | { steps: '', imageName: 'Portrait_5.jpg', label: 'Verify Orientation 5' }, 218 | { 219 | steps: 'rt=d:90', 220 | imageName: 'Portrait_5.jpg', 221 | label: 'Verify Orientation 5 @ 90 degrees', 222 | }, 223 | { 224 | steps: 'rt=d:180', 225 | imageName: 'Portrait_5.jpg', 226 | label: 'Verify Orientation 5 @ 180 degrees', 227 | }, 228 | { 229 | steps: 'rt=d:270', 230 | imageName: 'Portrait_5.jpg', 231 | label: 'Verify Orientation 5 @ 270 degrees', 232 | }, 233 | { steps: '', imageName: 'Portrait_6.jpg', label: 'Verify Orientation 6' }, 234 | { 235 | steps: 'rt=d:90', 236 | imageName: 'Portrait_6.jpg', 237 | label: 'Verify Orientation 6 @ 90 degrees', 238 | }, 239 | { 240 | steps: 'rt=d:180', 241 | imageName: 'Portrait_6.jpg', 242 | label: 'Verify Orientation 6 @ 180 degrees', 243 | }, 244 | { 245 | steps: 'rt=d:270', 246 | imageName: 'Portrait_6.jpg', 247 | label: 'Verify Orientation 6 @ 270 degrees', 248 | }, 249 | { steps: '', imageName: 'Portrait_7.jpg', label: 'Verify Orientation 7' }, 250 | { 251 | steps: 'rt=d:90', 252 | imageName: 'Portrait_7.jpg', 253 | label: 'Verify Orientation 7 @ 90 degrees', 254 | }, 255 | { 256 | steps: 'rt=d:180', 257 | imageName: 'Portrait_7.jpg', 258 | label: 'Verify Orientation 7 @ 180 degrees', 259 | }, 260 | { 261 | steps: 'rt=d:270', 262 | imageName: 'Portrait_7.jpg', 263 | label: 'Verify Orientation 7 @ 270 degrees', 264 | }, 265 | { steps: '', imageName: 'Portrait_8.jpg', label: 'Verify Orientation 8' }, 266 | { 267 | steps: 'rt=d:90', 268 | imageName: 'Portrait_8.jpg', 269 | label: 'Verify Orientation 8 @ 90 degrees', 270 | }, 271 | { 272 | steps: 'rt=d:180', 273 | imageName: 'Portrait_8.jpg', 274 | label: 'Verify Orientation 8 @ 180 degrees', 275 | }, 276 | { 277 | steps: 'rt=d:270', 278 | imageName: 'Portrait_8.jpg', 279 | label: 'Verify Orientation 8 @ 270 degrees', 280 | }, 281 | { 282 | steps: 'rs=w:320', 283 | imageName: 'Portrait_2.jpg', 284 | label: 'Verify Orientation 2 resized', 285 | }, 286 | { 287 | steps: 'rs=w:320', 288 | imageName: 'Portrait_3.jpg', 289 | label: 'Verify Orientation 3 resized', 290 | }, 291 | { 292 | steps: 'rs=w:320', 293 | imageName: 'Portrait_4.jpg', 294 | label: 'Verify Orientation 4 resized', 295 | }, 296 | { 297 | steps: 'rs=w:320', 298 | imageName: 'Portrait_5.jpg', 299 | label: 'Verify Orientation 5 resized', 300 | }, 301 | { 302 | steps: 'rs=w:320', 303 | imageName: 'Portrait_6.jpg', 304 | label: 'Verify Orientation 6 resized', 305 | }, 306 | { 307 | steps: 'rs=w:320', 308 | imageName: 'Portrait_7.jpg', 309 | label: 'Verify Orientation 7 resized', 310 | }, 311 | { 312 | steps: 'rs=w:320', 313 | imageName: 'Portrait_8.jpg', 314 | label: 'Verify Orientation 8 resized', 315 | }, 316 | ]; 317 | 318 | if (process.env.TRAVIS === undefined) { 319 | // not supported by travis CI 320 | module.exports = module.exports.concat([ 321 | { 322 | steps: 'fm=f:avif', 323 | label: 'use avif format', 324 | contentType: 'image/avif', 325 | }, 326 | { steps: 'fx-bl=s:5', label: 'sigma 5 blur' }, 327 | ]); 328 | } 329 | -------------------------------------------------------------------------------- /test/image-server.tests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'); 4 | var expect = chai.expect; 5 | var http = require('http'); 6 | var fs = require('fs'); 7 | var path = require('path'); 8 | var isteam = require('../'); 9 | var serverOptions = require('./image-server.config.js'); 10 | var serverRequests = require('./image-server.requests.js'); 11 | var etags = require('./image-server.etags.json').reduce(function (state, o) { 12 | state[o.url] = o.etag; 13 | return state; 14 | }, {}); 15 | 16 | describe('#Image Server', function () { 17 | var server; 18 | 19 | describe('#Server Pipeline', function () {}); 20 | 21 | describe('#Image Steps', function () { 22 | before(function (cb) { 23 | server = isteam.http.start(serverOptions); 24 | cb(); 25 | }); 26 | 27 | after(function (cb) { 28 | var sortedUrls = Object.keys(etags) 29 | .map(function (k) { 30 | return { url: k, etag: etags[k] }; 31 | }) 32 | .sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0)); // ordered 33 | fs.writeFileSync( 34 | path.join(__dirname, './image-server.etags.json'), 35 | JSON.stringify(sortedUrls, null, '\t'), 36 | 'utf8' 37 | ); 38 | 39 | isteam.http.stop(server); 40 | cb(); 41 | }); 42 | 43 | serverRequests.forEach(function (serverRequest) { 44 | serverRequest.options = serverRequest.options || {}; 45 | serverRequest.reqOptions = getReqFromImageSteps(serverRequest); 46 | it(`${ 47 | serverRequest.label 48 | }, request: ${serverRequest.reqOptions.method || 'GET'} ${serverRequest.reqOptions.url.replace('fm=f:raw', 'fm=f:png')} `, function (cb) { 49 | getResponse(serverRequest.reqOptions, function (err, res) { 50 | expect(res.statusCode).to.be.equal( 51 | serverRequest.options.statusCode || 200 52 | ); 53 | const etagKey = `${ 54 | serverRequest.options.method 55 | ? serverRequest.options.method + ' ' 56 | : '' 57 | }${serverRequest.reqOptions.url}`; 58 | const isNew = !(etagKey in etags); 59 | const requestEtag = etags[etagKey] || 'undefined'; 60 | etags[etagKey] = res.headers.etag; 61 | if (!isNew) { 62 | // don't validate etag if it's a new test 63 | expect(res.headers.etag || 'undefined').to.be.equal(requestEtag); 64 | } 65 | if (serverRequest.contentType) { 66 | expect(res.headers['content-type']).to.be.equal( 67 | serverRequest.contentType 68 | ); 69 | } 70 | cb(); 71 | }); 72 | }); 73 | }); 74 | }); 75 | }); 76 | 77 | function getReqFromImageSteps(serverRequest) { 78 | const steps = serverRequest.steps; 79 | const options = serverRequest.options || {}; 80 | const imgName = serverRequest.imageName || 'UP_steam_loco.jpg'; 81 | let fmt = serverRequest.imageName || /fm\=f\:/.test(steps) ? '' : '/fm=f:raw'; 82 | if (options.disableFormat) fmt = ''; 83 | if (steps.length === 0 && fmt) { 84 | fmt = fmt.substr(1); 85 | } 86 | const qs = serverRequest.qs || {}; 87 | if (qs.cache === undefined) qs.cache = 'false'; 88 | const qsArray = Object.keys(qs).map((k) => `${k}=${qs[k]}`); 89 | const queryString = qsArray.length === 0 ? '' : `?${qsArray.join('&')}`; 90 | 91 | const reqOptions = { 92 | protocol: 'http:', 93 | host: 'localhost', 94 | port: 13337, 95 | method: options.method || 'GET', 96 | headers: options.headers || {}, 97 | path: `/${imgName}/:/${steps}${fmt}${queryString}`, 98 | agent: false, // no pooling 99 | }; 100 | reqOptions.url = `${reqOptions.protocol}//${reqOptions.host}:${reqOptions.port}${reqOptions.path}`; 101 | 102 | return reqOptions; 103 | } 104 | 105 | function getResponse(reqOptions, cb) { 106 | http 107 | .request(reqOptions, (res) => { 108 | cb(null, res); 109 | res.resume(); // free the response 110 | }) 111 | .on('error', (err) => { 112 | cb(err); 113 | }) 114 | .end(); 115 | } 116 | -------------------------------------------------------------------------------- /test/image-steps.tests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | var chai = require('chai'); 5 | var expect = chai.expect; 6 | var getHashFromSteps = 7 | require('../lib/helpers/image-steps.js').getHashFromSteps; 8 | 9 | var filesPath = path.resolve(__dirname, './files'); 10 | 11 | describe('#XXHash', function () { 12 | it('Any object', function () { 13 | const res = getHashFromSteps({ hello: 'world' }); 14 | expect(res).to.equal(623007140); 15 | }); 16 | 17 | it('Any stringify-able value', function () { 18 | const res = getHashFromSteps(true); 19 | expect(res).to.equal(2113053669); 20 | }); 21 | 22 | it('Obj 2', function () { 23 | const res = getHashFromSteps({ something: 'unique' }); 24 | expect(res).to.equal(1136321441); 25 | }); 26 | 27 | it('Obj 3', function () { 28 | const res = getHashFromSteps({ something: 'unique2' }); 29 | expect(res).to.equal(422463611); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | --ui bdd 3 | --recursive 4 | -r should 5 | -------------------------------------------------------------------------------- /test/security.tests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'); 4 | var expect = chai.expect; 5 | var http = require('http'); 6 | var isteam = require('../'); 7 | var Security = require('../lib/security'); 8 | var serverOptions = require('./image-server.config.js'); 9 | var serverRequests = require('./image-server.requests.js'); 10 | var crypto = require('crypto'); 11 | 12 | describe('#Image Server Security', function () { 13 | it('Throws an error if options.secret is not defined', function () { 14 | expect(function () { 15 | new Security({ 16 | enabled: true, 17 | }); 18 | }).to.throw( 19 | Security.SecurityError, 20 | 'You must set a secret to enable Security' 21 | ); 22 | }); 23 | 24 | var server; 25 | var secret = 'keyboard_cat'; 26 | 27 | before(function () { 28 | serverOptions.security = { 29 | enabled: true, 30 | secret: secret, 31 | algorithm: 'sha256', 32 | }; 33 | 34 | server = isteam.http.start(serverOptions); 35 | }); 36 | 37 | after(function () { 38 | isteam.http.stop(server); 39 | }); 40 | 41 | serverRequests.forEach(function (serverRequest) { 42 | var url = getUrlFromImageSteps(serverRequest); 43 | if (!url) return; 44 | it( 45 | serverRequest.label + ', url: ' + url + ' good signature', 46 | function (url, cb) { 47 | getResponse(url, function (err, res) { 48 | expect(res.statusCode).to.be.equal(200); 49 | if (serverRequest.contentType) { 50 | expect(res.headers['content-type']).to.be.equal( 51 | serverRequest.contentType 52 | ); 53 | } 54 | cb(); 55 | }); 56 | }.bind({}, url) 57 | ); 58 | }); 59 | 60 | serverRequests.forEach(function (serverRequest) { 61 | var url = getUrlFromImageSteps(serverRequest, 'bogussig'); 62 | if (!url) return; 63 | it( 64 | serverRequest.label + ', url: ' + url + ' bad signature', 65 | function (url, cb) { 66 | getResponse(url, function (err, res) { 67 | expect(res.statusCode).to.be.equal(401); 68 | cb(); 69 | }); 70 | }.bind({}, url) 71 | ); 72 | }); 73 | 74 | function getUrlFromImageSteps(serverRequest, signature) { 75 | var options = serverRequest.options || {}; 76 | if (options.security === false) return; 77 | var steps = serverRequest.steps; 78 | var imgName = serverRequest.imageName || 'UP_steam_loco.jpg'; 79 | 80 | if (!signature) { 81 | var shasum = crypto.createHash('sha256'); 82 | shasum.update('/' + imgName + '/:/' + steps + secret); 83 | signature = shasum 84 | .digest('base64') 85 | .replace(/\//g, '_') 86 | .replace(/\+/g, '-') 87 | .substring(0, 8); 88 | } 89 | 90 | return ( 91 | 'http://localhost:13337/' + 92 | imgName + 93 | '/:/' + 94 | steps + 95 | '/-/' + 96 | signature + 97 | '?cache=false' 98 | ); 99 | } 100 | 101 | function getResponse(url, cb) { 102 | http 103 | .get(url, function (res) { 104 | cb(null, res); 105 | res.resume(); // free the response 106 | }) 107 | .on('error', function (err) { 108 | cb(err); 109 | }); 110 | } 111 | }); 112 | --------------------------------------------------------------------------------