├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .swcrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── eslint.config.js ├── package.json ├── prettier.config.js ├── src ├── callback.ts ├── index.ts ├── internals.ts ├── models.ts └── stream.ts ├── test.env ├── test ├── buffersAndStreams.test.ts ├── config │ ├── c8-ci.json │ └── c8-local.json ├── files.test.ts ├── fixtures │ └── image.png ├── index.test.ts ├── streams.test.ts └── urls.test.ts ├── tsconfig.json └── tsconfig.test.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: [push, pull_request, workflow_dispatch] 4 | jobs: 5 | ci: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v3 10 | - name: Use supported Node.js Version 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: 20.18.0 14 | - name: Restore cached dependencies 15 | uses: actions/cache@v3 16 | with: 17 | path: ~/.pnpm-store 18 | key: node-modules-${{ hashFiles('package.json') }} 19 | - name: Setup pnpm 20 | uses: pnpm/action-setup@v2 21 | with: 22 | version: latest 23 | - name: Install dependencies 24 | run: pnpm install --shamefully-hoist 25 | - name: Run Tests 26 | run: pnpm run ci 27 | - name: Upload coverage to Codecov 28 | uses: codecov/codecov-action@v3 29 | with: 30 | file: ./coverage/coverage-final.json 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | types/ 3 | coverage/ -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "target": "esnext", 4 | "parser": { 5 | "syntax": "typescript", 6 | "tsx": false, 7 | "dynamicImport": true 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 2024-12-28 / 6.0.1 2 | 3 | - chore: Updated dependencies. 4 | - chore: Fixed Node.js version in CI. 5 | 6 | ### 2024-10-20 / 6.0.0 7 | 8 | - feat!: Dropped support for Node 18 and updated dependencies. 9 | 10 | ### 2024-04-12 / 5.0.5 11 | 12 | - chore: Updated dependencies. 13 | - chore: Added missing dependency. 14 | 15 | ### 2024-02-07 / 5.0.4 16 | 17 | - chore: Updated dependencies. 18 | - chore: Replaced tap with Node test runner. 19 | 20 | ### 2024-01-27 / 5.0.3 21 | 22 | - chore: Fixed build. 23 | 24 | ### 2024-01-27 / 5.0.2 25 | 26 | - chore: Updated dependencies. 27 | 28 | ### 2024-01-24 / 5.0.1 29 | 30 | - chore: Updated TypeScript configuration. 31 | 32 | ### 2023-12-20 / 5.0.0 33 | 34 | - chore: Updated dependencies. 35 | - chore: Changed TypeScript version. 36 | 37 | ### 2023-10-23 / 4.1.0 38 | 39 | - chore: Updated dependencies and toolchain. 40 | - chore: Fixed compilation. 41 | - chore: CI improvement 42 | 43 | ### 2022-11-23 / 4.0.12 44 | 45 | - chore: Updated dependencies. 46 | - chore: Update package.json 47 | - fix: Fixed build script. 48 | 49 | ### 2022-10-12 / 4.0.11 50 | 51 | - fix: Updated types layout. 52 | - chore: Updated compilation configuration. 53 | 54 | ### 2022-08-30 / 4.0.10 55 | 56 | - chore: Updated dependencies. 57 | - chore: Remove variadic method. 58 | 59 | ### 2022-08-29 / 4.0.9 60 | 61 | - chore: Updated dependencies. 62 | - fix: Fixed CI. 63 | 64 | ### 2022-05-02 / 4.0.8 65 | 66 | - fix: Correctly Export models. 67 | 68 | ### 2022-05-02 / 4.0.7 69 | 70 | - fix: Export models. 71 | 72 | ### 2022-05-02 / 4.0.6 73 | 74 | - fix: Export models. 75 | - chore: Use sourcemaps with swc 76 | 77 | ### 2022-03-07 / 4.0.5 78 | 79 | - chore: Updated dependencies. 80 | 81 | ### 2022-03-07 / 4.0.4 82 | 83 | - chore: Updated dependencies. 84 | 85 | ### 2022-01-26 / 4.0.3 86 | 87 | - chore: Updated dependencies and linted code. 88 | - chore: Updated dependencies. 89 | - chore: Removed useless file. 90 | 91 | ### 2021-11-17 / 4.0.2 92 | 93 | 94 | ### 2021-11-16 / 4.0.1 95 | 96 | - fix: Added ESM note in the README.md 97 | - chore: Allow manual CI triggering. 98 | - chore: Updated badges. 99 | - fix: Fixed Typescript configuration. 100 | 101 | ### 2021-08-24 / 4.0.0 102 | 103 | - feat: Only export as ESM. 104 | - fix: Removed useless comment. 105 | - chore: Fine tuned build script. 106 | 107 | ### 2021-01-04 / 3.2.0 108 | 109 | - feat: Use different versioning for User-Agent. 110 | 111 | ### 2021-01-04 / 3.1.0 112 | 113 | - feat: Export as ESM. 114 | 115 | ### 2021-01-03 / 3.0.2 116 | 117 | - chore: Fixed license link in README.md. 118 | 119 | ### 2021-01-03 / 3.0.1 120 | 121 | - chore: Updated linter config. 122 | - chore: Removed IDE files. 123 | 124 | ### 2021-01-03 / 3.0.0 125 | 126 | - **Simplified to only `info` and `stream` methods.** 127 | - **Dropped supported for Node < 12**. 128 | - Completely rewritten in TypeScript. 129 | 130 | ### 2016-10-29 / 2.0.1 131 | 132 | - Updated request package version to fix a vulnerability. Thanks to @gazay. 133 | 134 | ### 2016-03-23 / 2.0.0 135 | 136 | - **Dropped support for Node < 5.** 137 | - `fastimage.info` always includes `realPath` and `realUrl` instead of omitting them if equals to `path` and `url`. 138 | 139 | ### 2016-03-08 / 1.2.0 140 | 141 | - `fastimage.threshold` can now accept value less than or equal to zero to disable the feature and try to download/open the entire file/stream. This fixes detection of corrupted files. 142 | 143 | ### 2016-03-07 / 1.1.1 144 | 145 | - Bumped version in package.json. 146 | 147 | ### 2016-03-07 / 1.1.0 148 | 149 | - Added `fastimage.userAgent` to enable User Agent handling. Thanks to matcarey. 150 | 151 | ### 2015-05-25 / 1.0.2 152 | 153 | - Ensured promise is included as dependency. 154 | 155 | ### 2015-05-06 / 1.0.1 156 | 157 | - Ensured Node 0.10 compatibility. 158 | 159 | ### 2015-03-28 / 1.0.0 160 | 161 | - Renamed `fastimage.analyze` as `fastimage.info`. 162 | - Added `fastimage.filteredInfo` to filter the result object. 163 | - Make `fastimage.info`, `fastimage.filteredInfo`, `fastimage.size` and `fastimage.type` return a Promise. 164 | - Added `fastimage.stream` and exported `FastImageStream` for streaming support. 165 | - Support for analyzing Buffers. 166 | - Added examples and test. 167 | 168 | ### 2015-03-14 / 0.2.0 - The PI release! 169 | 170 | - Added support for analyzing local files. 171 | - Added `realPath`, `realUrl` and `size` to the returned objects. 172 | - Added documentation. 173 | - Improved README. 174 | 175 | ### 2015-03-10 / 0.1.1 176 | 177 | - Export `FastImageError` as well. 178 | 179 | ### 2015-03-10 / 0.1.0 180 | 181 | - Initial version 182 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2015, and above Shogun 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastimage 2 | 3 | [![Version](https://img.shields.io/npm/v/fastimage.svg)](https://npm.im/fastimage) 4 | [![Dependencies](https://img.shields.io/librariesio/release/npm/fastimage)](https://libraries.io/npm/fastimage) 5 | [![Build](https://github.com/ShogunPanda/fastimage/workflows/CI/badge.svg)](https://github.com/ShogunPanda/fastimage/actions?query=workflow%3ACI) 6 | [![Coverage](https://img.shields.io/codecov/c/gh/ShogunPanda/fastimage?token=KMA8EPI3DI)](https://codecov.io/gh/ShogunPanda/fastimage) 7 | 8 | A module that finds the size and type of an image by fetching and reading as little data as needed. 9 | 10 | http://sw.cowtech.it/fastimage 11 | 12 | ## Installation 13 | 14 | Just run: 15 | 16 | ```bash 17 | npm install fastimage 18 | ``` 19 | 20 | ## Usage 21 | 22 | The signature is `fastimage.info(source, [options], [callback])`. 23 | 24 | The `source` argument can be: 25 | 26 | - String representing a URL (only `http` and `https` protocol are supported). 27 | - String representing a local file path. 28 | - Buffer containing image data. 29 | - Stream containing image data. 30 | 31 | The `options` object supports the following options: 32 | 33 | - `threshold`: The maximum about of data (in bytes) to downloaded or read before giving up. Default is `4096`. 34 | - `timeout`: The maximum time (in milliseconds) to wait for a URL to be downloaded before giving up. Default is `30000` (30 s). 35 | - `userAgent`: The user agent to use when making HTTP(S) requests. Default is `fastimage/$VERSION`. 36 | 37 | If `callback` is not provided, the method returns a `Promise`. 38 | 39 | ## Example 40 | 41 | ```js 42 | import { info } from 'fastimage' 43 | 44 | info('http://fakeimg.pl/1000x1000/', (error, data) => { 45 | if (error) { 46 | // ... 47 | } else { 48 | // ... 49 | } 50 | }) 51 | 52 | const data = await info('http://fakeimg.pl/1000x1000/') 53 | ``` 54 | 55 | The callback argument (or the resolved value) will be an object with the following properties: 56 | 57 | ```js 58 | { 59 | "width": 1000, // The width of the image in pixels. 60 | "height": 1000, // The height of the image in pixels. 61 | "type": "png", // The type of the image. Can be one of the supported images formats (see section below). 62 | "time": 171.43721 // The time required for the operation, in milliseconds. 63 | "analyzed": 979, // The amount of data transferred (in bytes) or read (in case of files or Buffer) to identify the image. 64 | "realUrl": "https://fakeimg.pl/1000x1000/", // The final URL of the image after all the redirects. Only present if the source was a URL. 65 | "size": 17300, // The size of the image (in bytes). Only present if the source was a URL and if the server returned the "Content-Length" HTTP header. 66 | } 67 | ``` 68 | 69 | ## Streams 70 | 71 | Calling `fastimage.stream` it will return a Writable stream which will emit the `info` event once informations are ready. 72 | 73 | The stream accepts only the `threshold` option. 74 | 75 | ```js 76 | import { stream } from 'fastimage' 77 | 78 | const pipe = createReadStream('/path/to/image.png').pipe(stream({ threshold: 100 })) 79 | 80 | pipe.on('info', data => { 81 | // ... 82 | }) 83 | ``` 84 | 85 | ## Supported image formats 86 | 87 | The supported image type are (thanks to the [image-size](https://github.com/netroy/image-size) module): 88 | 89 | - BMP 90 | - CUR 91 | - DDS 92 | - GIF 93 | - ICNS 94 | - ICO 95 | - JPEG 96 | - KTX 97 | - PNG 98 | - PNM (PAM, PBM, PFM, PGM, PPM) 99 | - PSD 100 | - SVG 101 | - TIFF 102 | - WebP 103 | 104 | ## ESM Only 105 | 106 | This package only supports to be directly imported in a ESM context. 107 | 108 | For informations on how to use it in a CommonJS context, please check [this page](https://gist.github.com/ShogunPanda/fe98fd23d77cdfb918010dbc42f4504d). 109 | 110 | ## Contributing to fastimage 111 | 112 | - Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet. 113 | - Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it. 114 | - Fork the project. 115 | - Start a feature/bugfix branch. 116 | - Commit and push until you are happy with your contribution. 117 | - Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. 118 | 119 | ## Copyright 120 | 121 | Copyright (C) 2015 and above Shogun (shogun@cowtech.it). 122 | 123 | Licensed under the MIT license, which can be found at https://choosealicense.com/licenses/isc. 124 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { cowtech } from '@cowtech/eslint-config' 2 | 3 | export default [ 4 | ...cowtech, 5 | { 6 | languageOptions: { 7 | parserOptions: { 8 | project: './tsconfig.test.json' 9 | } 10 | } 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastimage", 3 | "version": "6.0.1", 4 | "description": "A module that finds the size and type of an image by fetching and reading as little data as needed.", 5 | "homepage": "https://sw.cowtech.it/fastimage", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/ShogunPanda/fastimage.git" 9 | }, 10 | "keywords": [ 11 | "fast", 12 | "fastimage", 13 | "image", 14 | "size", 15 | "dimensions", 16 | "resolution", 17 | "width", 18 | "height", 19 | "png", 20 | "jpeg", 21 | "bmp", 22 | "gif", 23 | "psd", 24 | "tiff", 25 | "webp", 26 | "svg" 27 | ], 28 | "bugs": { 29 | "url": "https://github.com/ShogunPanda/fastimage/issues" 30 | }, 31 | "author": "Shogun ", 32 | "license": "ISC", 33 | "private": false, 34 | "files": [ 35 | "dist", 36 | "CHANGELOG.md", 37 | "LICENSE.md", 38 | "README.md" 39 | ], 40 | "type": "module", 41 | "exports": "./dist/index.js", 42 | "types": "./dist/index.d.ts", 43 | "scripts": { 44 | "dev": "swc --strip-leading-paths --delete-dir-on-start -s -w -d dist src", 45 | "build": "swc --strip-leading-paths --delete-dir-on-start -d dist src", 46 | "postbuild": "concurrently npm:lint npm:typecheck", 47 | "format": "prettier -w src test", 48 | "lint": "eslint --cache", 49 | "typecheck": "tsc -p . --emitDeclarationOnly", 50 | "test": "c8 -c test/config/c8-local.json node --env-file=test.env --test test/*.test.ts", 51 | "test:ci": "c8 -c test/config/c8-ci.json node --env-file=test.env --test-reporter=tap --test test/*.test.ts", 52 | "ci": "npm run build && npm run test:ci", 53 | "prepublishOnly": "npm run ci", 54 | "postpublish": "git push origin && git push origin -f --tags" 55 | }, 56 | "dependencies": { 57 | "image-size": "^1.2.0", 58 | "undici": "^7.2.0" 59 | }, 60 | "devDependencies": { 61 | "@cowtech/eslint-config": "10.2.0", 62 | "@swc-node/register": "^1.10.9", 63 | "@swc/cli": "0.5.2", 64 | "@swc/core": "^1.10.3", 65 | "@types/node": "^22.10.2", 66 | "c8": "^10.1.3", 67 | "chokidar": "^4.0.3", 68 | "concurrently": "^9.1.1", 69 | "eslint": "^9.17.0", 70 | "prettier": "^3.4.2", 71 | "typescript": "^5.7.2" 72 | }, 73 | "engines": { 74 | "node": ">= 20.18.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | printWidth: 120, 3 | semi: false, 4 | singleQuote: true, 5 | bracketSpacing: true, 6 | trailingComma: 'none', 7 | arrowParens: 'avoid' 8 | } 9 | -------------------------------------------------------------------------------- /src/callback.ts: -------------------------------------------------------------------------------- 1 | import { type ImageInfo } from './models.js' 2 | 3 | export type Callback = (error: Error | null, info?: ImageInfo) => void 4 | type PromiseResolver = (value: T) => void 5 | type PromiseRejecter = (error: Error) => void 6 | 7 | export function ensurePromiseCallback(callback?: Callback): [Callback, Promise?] { 8 | if (typeof callback === 'function') { 9 | return [callback] 10 | } 11 | 12 | let promiseResolve: PromiseResolver, promiseReject: PromiseRejecter 13 | 14 | const promise = new Promise((resolve, reject) => { 15 | promiseResolve = resolve 16 | promiseReject = reject 17 | }) 18 | 19 | return [ 20 | (err, info) => { 21 | if (err) { 22 | promiseReject(err) 23 | return 24 | } 25 | 26 | promiseResolve(info!) 27 | }, 28 | promise 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events' 2 | import { type Stream, type Writable, type WritableOptions } from 'node:stream' 3 | import { ensurePromiseCallback, type Callback } from './callback.js' 4 | import { handleData, handleError, toStream } from './internals.js' 5 | import { FastImageError, defaultOptions, type ImageInfo, type Options } from './models.js' 6 | import { FastImageStream } from './stream.js' 7 | 8 | export { FastImageError, defaultOptions } from './models.js' 9 | 10 | export async function info( 11 | source: string | Stream | Buffer, 12 | options?: Partial | Callback, 13 | cb?: Callback 14 | ): Promise { 15 | // Normalize arguments 16 | if (typeof options === 'function') { 17 | cb = options 18 | options = {} 19 | } 20 | 21 | const { timeout, threshold, userAgent } = { ...defaultOptions, ...options } 22 | 23 | // Prepare execution 24 | let finished = false 25 | let buffer = Buffer.alloc(0) 26 | const [callback, promise] = ensurePromiseCallback(cb) 27 | const start = process.hrtime.bigint() 28 | 29 | // Make sure the source is always a Stream 30 | try { 31 | const aborter = new EventEmitter() 32 | const [stream, url, headers] = await toStream(source, timeout, threshold, userAgent, aborter) 33 | 34 | stream.on('data', chunk => { 35 | if (finished) { 36 | return 37 | } 38 | 39 | buffer = Buffer.concat([buffer, chunk]) 40 | finished = handleData(buffer, headers, threshold, start, aborter, callback) 41 | }) 42 | 43 | stream.on('error', (error: FastImageError) => { 44 | callback(handleError(error, url!)) 45 | }) 46 | 47 | stream.on('end', () => { 48 | if (finished) { 49 | return 50 | } 51 | 52 | // We have reached the end without figuring the image type. Just give up 53 | callback(new FastImageError('Unsupported data.', 'UNSUPPORTED')) 54 | }) 55 | 56 | return promise! 57 | } catch (error) { 58 | callback(error as Error) 59 | return promise! 60 | } 61 | } 62 | 63 | export function stream(options?: Partial & Partial): Writable { 64 | return new FastImageStream(options ?? {}) 65 | } 66 | -------------------------------------------------------------------------------- /src/internals.ts: -------------------------------------------------------------------------------- 1 | import { imageSize } from 'image-size' 2 | import type EventEmitter from 'node:events' 3 | import { createReadStream } from 'node:fs' 4 | import { type IncomingHttpHeaders } from 'node:http' 5 | import { Readable, type Stream } from 'node:stream' 6 | import undici from 'undici' 7 | import { type Callback } from './callback.js' 8 | import { FastImageError, type ImageInfo } from './models.js' 9 | 10 | const realUrlHeader = 'x-fastimage-real-url' 11 | 12 | export async function toStream( 13 | source: string | Stream | Buffer, 14 | timeout: number, 15 | threshold: number, 16 | userAgent: string, 17 | aborter: EventEmitter 18 | ): Promise<[Stream, string | undefined, IncomingHttpHeaders | undefined]> { 19 | let url: string | undefined 20 | let headers: IncomingHttpHeaders | undefined 21 | const highWaterMark = threshold > 0 ? Math.floor(threshold / 10) : 1024 22 | 23 | // If the source is a buffer, get it as stream 24 | if (Buffer.isBuffer(source)) { 25 | source = Readable.from(source, { highWaterMark }) 26 | } else if (typeof source === 'string') { 27 | // Try to parse the source as URL - If it succeeds, we will fetch it 28 | try { 29 | const parsedUrl = new URL(source) 30 | 31 | if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { 32 | throw new FastImageError('Invalid URL.', 'URL_ERROR', parsedUrl.toString()) 33 | } 34 | 35 | url = source 36 | 37 | const { 38 | statusCode, 39 | headers: responseHeaders, 40 | body, 41 | context 42 | } = await undici.request(parsedUrl, { 43 | method: 'GET', 44 | headers: { 'user-agent': userAgent }, 45 | signal: aborter, 46 | dispatcher: new undici.Agent({ 47 | headersTimeout: timeout, 48 | bodyTimeout: timeout, 49 | pipelining: 0 50 | }).compose(undici.interceptors.redirect({ maxRedirections: 10 })) 51 | }) 52 | 53 | if (statusCode > 299) { 54 | throw new FastImageError(`Remote host replied with HTTP ${statusCode}.`, 'NETWORK_ERROR', url) 55 | } 56 | 57 | source = body 58 | headers = responseHeaders 59 | headers[realUrlHeader] = (context as any).history.pop().toString() 60 | } catch (error) { 61 | if ((error as FastImageError).code === 'FASTIMAGE_URL_ERROR') { 62 | throw error 63 | } else if (url) { 64 | throw handleError(error as FastImageError, url) 65 | } 66 | 67 | // Parsing failed. Treat as local file 68 | source = createReadStream(source as string, { highWaterMark }) 69 | } 70 | } 71 | 72 | return [source, url, headers] 73 | } 74 | 75 | export function handleData( 76 | buffer: Buffer, 77 | headers: IncomingHttpHeaders | undefined, 78 | threshold: number, 79 | start: bigint, 80 | aborter: EventEmitter, 81 | callback: Callback 82 | ): boolean { 83 | try { 84 | const info = imageSize(buffer) 85 | 86 | const data: ImageInfo = { 87 | width: info.width!, 88 | height: info.height!, 89 | type: info.type!, 90 | time: Number(process.hrtime.bigint() - start) / 1e6, 91 | analyzed: buffer.length 92 | } 93 | 94 | // Add URL informations 95 | if (headers) { 96 | data.realUrl = headers[realUrlHeader] as string 97 | 98 | if ('content-length' in headers) { 99 | data.size = Number.parseInt(headers['content-length']!, 10) 100 | } 101 | } 102 | 103 | // Close the URL if possible 104 | aborter.emit('abort') 105 | 106 | callback(null, data) 107 | return true 108 | } catch { 109 | // Check threshold 110 | if (threshold > 0 && buffer.length > threshold) { 111 | aborter.emit('abort') 112 | 113 | callback(new FastImageError('Unsupported data.', 'UNSUPPORTED')) 114 | return true 115 | } 116 | 117 | return false 118 | } 119 | } 120 | 121 | export function handleError(error: FastImageError, url: string): Error { 122 | let message = null 123 | let code = 'NETWORK_ERROR' 124 | 125 | switch (error.code) { 126 | case 'EISDIR': 127 | code = 'FS_ERROR' 128 | message = 'Source is a directory.' 129 | break 130 | case 'ENOENT': 131 | code = 'FS_ERROR' 132 | message = 'Source not found.' 133 | break 134 | case 'EACCES': 135 | code = 'FS_ERROR' 136 | message = 'Source is not readable.' 137 | break 138 | case 'ENOTFOUND': 139 | message = 'Invalid remote host requested.' 140 | break 141 | /* c8 ignore next 2 */ 142 | case 'ECONNRESET': 143 | case 'EPIPE': 144 | case 'UND_ERR_SOCKET': 145 | message = 'Connection with the remote host interrupted.' 146 | break 147 | case 'ECONNREFUSED': 148 | message = 'Connection refused from the remote host.' 149 | break 150 | /* c8 ignore next 2 */ 151 | case 'ETIMEDOUT': 152 | case 'UND_ERR_HEADERS_TIMEOUT': 153 | message = 'Connection to the remote host timed out.' 154 | break 155 | } 156 | 157 | if (message) { 158 | error = new FastImageError(message, code, url) 159 | } 160 | 161 | return error 162 | } 163 | -------------------------------------------------------------------------------- /src/models.ts: -------------------------------------------------------------------------------- 1 | // The version is dynamically generated via build script in order not rely on require in the ESM case. 2 | 3 | export interface ImageInfo { 4 | width: number 5 | height: number 6 | type: string 7 | time: number 8 | analyzed: number 9 | realUrl?: string 10 | size?: number 11 | } 12 | 13 | export interface Options { 14 | timeout: number 15 | threshold: number 16 | userAgent: string 17 | } 18 | 19 | export class FastImageError extends Error { 20 | code: string 21 | url?: string 22 | httpResponseCode?: number 23 | 24 | constructor(message: string, code: string, url?: string, httpResponseCode?: number) { 25 | super(message) 26 | this.name = 'FastImageError' 27 | this.code = `FASTIMAGE_${code}` 28 | this.url = url 29 | this.httpResponseCode = httpResponseCode 30 | } 31 | } 32 | 33 | // Since it's harder to keep this in sync with package.json, let's use a different number. 34 | export const userAgentVersion = '1.0.0' 35 | 36 | export const defaultOptions: Options = { 37 | timeout: 30_000, 38 | threshold: 4096, 39 | userAgent: `fastimage/${userAgentVersion}` 40 | } 41 | -------------------------------------------------------------------------------- /src/stream.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events' 2 | import { Writable, type WritableOptions } from 'node:stream' 3 | import { handleData } from './internals.js' 4 | import { FastImageError, defaultOptions, type Options } from './models.js' 5 | 6 | export class FastImageStream extends Writable { 7 | buffer: Buffer 8 | threshold: number 9 | start: bigint 10 | finished: boolean 11 | 12 | constructor(options: Partial & WritableOptions) { 13 | super(options as WritableOptions) 14 | 15 | this.threshold = options.threshold ?? defaultOptions.threshold 16 | this.buffer = Buffer.alloc(0) 17 | this.start = process.hrtime.bigint() 18 | this.finished = false 19 | } 20 | 21 | analyze(chunk: Buffer): void { 22 | this.buffer = Buffer.concat([this.buffer, chunk]) 23 | 24 | this.finished = handleData( 25 | this.buffer, 26 | undefined, 27 | this.threshold, 28 | this.start, 29 | new EventEmitter(), 30 | (error, data) => { 31 | if (error) { 32 | this.emit('error', error) 33 | } else { 34 | this.emit('info', data) 35 | } 36 | 37 | this.destroy() 38 | } 39 | ) 40 | } 41 | 42 | /* c8 ignore start */ 43 | _write(chunk: Buffer, _e: BufferEncoding, cb: (error?: Error | null) => void): void { 44 | this.analyze(chunk) 45 | cb() 46 | } 47 | 48 | _writev(chunks: { chunk: Buffer }[], cb: (error?: Error | null) => void): void { 49 | for (const { chunk } of chunks) { 50 | this.analyze(chunk) 51 | } 52 | 53 | cb() 54 | } 55 | /* c8 ignore stop */ 56 | 57 | _final(cb: (error?: Error | null) => void): void { 58 | /* c8 ignore next 4 */ 59 | if (this.finished) { 60 | cb() 61 | return 62 | } 63 | 64 | cb(new FastImageError('Unsupported data.', 'UNSUPPORTED')) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test.env: -------------------------------------------------------------------------------- 1 | NODE_OPTIONS=--import @swc-node/register/esm-register -------------------------------------------------------------------------------- /test/buffersAndStreams.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual, rejects } from 'node:assert' 2 | import { createReadStream, readFileSync } from 'node:fs' 3 | import { test } from 'node:test' 4 | import { info } from '../src/index.js' 5 | import { FastImageError } from '../src/models.js' 6 | 7 | const fileName = import.meta.url.replace('file://', '') 8 | const imagePath = new URL('fixtures/image.png', import.meta.url).toString().replace('file://', '') 9 | 10 | test('fastimage.info', async () => { 11 | await test('when working with buffers', async () => { 12 | await test('should return the information of a image', async () => { 13 | const buffer = readFileSync(imagePath) 14 | 15 | const data = await info(buffer) 16 | 17 | deepStrictEqual(data, { 18 | width: 150, 19 | height: 150, 20 | type: 'png', 21 | time: data.time, 22 | analyzed: 24_090 23 | }) 24 | }) 25 | 26 | await test('should return a error when the data is not a image', async () => { 27 | const buffer = readFileSync(fileName) 28 | 29 | await rejects(info(buffer), new FastImageError('Unsupported data.', 'UNSUPPORTED')) 30 | }) 31 | }) 32 | 33 | await test('when working with streams', async () => { 34 | await test('should return the information of a image', async () => { 35 | const data = await info(createReadStream(imagePath)) 36 | 37 | deepStrictEqual(data, { 38 | width: 150, 39 | height: 150, 40 | type: 'png', 41 | time: data.time, 42 | analyzed: 24_090 43 | }) 44 | }) 45 | 46 | await test('should return a error when the data is not a image', async () => { 47 | await rejects(info(fileName), new FastImageError('Unsupported data.', 'UNSUPPORTED')) 48 | }) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /test/config/c8-ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "check-coverage": true, 3 | "reporter": ["text", "json"], 4 | "branches": 90, 5 | "functions": 90, 6 | "lines": 90, 7 | "statements": 90 8 | } 9 | -------------------------------------------------------------------------------- /test/config/c8-local.json: -------------------------------------------------------------------------------- 1 | { 2 | "reporter": ["text", "html"] 3 | } 4 | -------------------------------------------------------------------------------- /test/files.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual, ifError } from 'node:assert' 2 | import { chmodSync, existsSync, unlinkSync, writeFileSync } from 'node:fs' 3 | import { dirname } from 'node:path' 4 | import { test } from 'node:test' 5 | import { info } from '../src/index.js' 6 | import { FastImageError } from '../src/models.js' 7 | 8 | const fileName = import.meta.url.replace('file://', '') 9 | const imagePath = new URL('fixtures/image.png', import.meta.url).toString().replace('file://', '') 10 | 11 | test('fastimage.info', async () => { 12 | await test('when working with local files', async () => { 13 | await test('should return the information of a image', () => { 14 | info(imagePath, (error, data) => { 15 | ifError(error) 16 | 17 | deepStrictEqual(data, { 18 | width: 150, 19 | height: 150, 20 | type: 'png', 21 | time: data!.time, 22 | analyzed: 409 23 | }) 24 | }) 25 | }) 26 | 27 | await test('should return a error when the path is a directory', () => { 28 | info(dirname(fileName), (error, data) => { 29 | ifError(data) 30 | deepStrictEqual(error, new FastImageError('Source is a directory.', 'FS_ERROR')) 31 | }) 32 | }) 33 | 34 | await test('should return a error when the path cannot be found', () => { 35 | info('/not/existent', (error, data) => { 36 | ifError(data) 37 | deepStrictEqual(error, new FastImageError('Source not found.', 'FS_ERROR')) 38 | }) 39 | }) 40 | 41 | await test('should return a error when the path cannot be read', () => { 42 | const unreadablePath = imagePath.replace('image.png', 'unreadable.png') 43 | 44 | if (!existsSync(unreadablePath)) { 45 | writeFileSync(unreadablePath, 'foo', 'utf8') 46 | chmodSync(unreadablePath, 0) 47 | } 48 | 49 | info(unreadablePath, (error, data) => { 50 | ifError(data) 51 | deepStrictEqual(error, new FastImageError('Source is not readable.', 'FS_ERROR')) 52 | 53 | unlinkSync(unreadablePath) 54 | }) 55 | }) 56 | 57 | await test('should return a error when the path is not a image', () => { 58 | info(fileName, (error, data) => { 59 | ifError(data) 60 | deepStrictEqual(error, new FastImageError('Unsupported data.', 'UNSUPPORTED')) 61 | }) 62 | }) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /test/fixtures/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShogunPanda/fastimage/e1c62592f74af34bf1c8a402c4c4f14de795c324/test/fixtures/image.png -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { rejects } from 'node:assert' 2 | import { test } from 'node:test' 3 | import { info } from '../src/index.js' 4 | import { FastImageError } from '../src/models.js' 5 | 6 | test('fastimage.info', async () => { 7 | await test('side cases', async () => { 8 | const url = 9 | 'https://commons.wikimedia.org/wiki/Category:JPG_corruption_example_images#/media/File:JPEG_Corruption.jpg' 10 | 11 | await rejects(info(url), new FastImageError('Unsupported data.', 'UNSUPPORTED')) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /test/streams.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual } from 'node:assert' 2 | import { createReadStream } from 'node:fs' 3 | import { test } from 'node:test' 4 | import { stream } from '../src/index.js' 5 | import { FastImageError } from '../src/models.js' 6 | 7 | const fileName = import.meta.url.replace('file://', '') 8 | const imagePath = new URL('fixtures/image.png', import.meta.url).toString().replace('file://', '') 9 | 10 | test('fastimage.stream', async () => { 11 | await test('should emit info event when info are ready', () => { 12 | const input = createReadStream(imagePath, { highWaterMark: 200 }) 13 | 14 | const pipe = input.pipe(stream()) 15 | 16 | pipe.on('info', data => { 17 | deepStrictEqual(data, { 18 | width: 150, 19 | height: 150, 20 | type: 'png', 21 | time: data.time, 22 | analyzed: 200 23 | }) 24 | }) 25 | }) 26 | 27 | await test('should emit error event in case of errors', () => { 28 | const input = createReadStream(fileName) 29 | 30 | const pipe = input.pipe(stream()) 31 | 32 | pipe.on('error', error => { 33 | deepStrictEqual(error, new FastImageError('Unsupported data.', 'UNSUPPORTED')) 34 | }) 35 | }) 36 | 37 | await test('should accept the threshold option', () => { 38 | const input = createReadStream(imagePath, { highWaterMark: 1 }) 39 | 40 | const pipe = input.pipe(stream({ threshold: 10 })) 41 | 42 | pipe.on('error', error => { 43 | deepStrictEqual(error, new FastImageError('Unsupported data.', 'UNSUPPORTED')) 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /test/urls.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual, ok, rejects } from 'node:assert' 2 | import { readFileSync } from 'node:fs' 3 | import { createServer as createHttpServer } from 'node:http' 4 | import { createServer, type AddressInfo } from 'node:net' 5 | import { test } from 'node:test' 6 | import { info } from '../src/index.js' 7 | import { FastImageError, userAgentVersion } from '../src/models.js' 8 | 9 | test('fastimage.info', async () => { 10 | await test('when working with URLS', async () => { 11 | await test('should return the information of a image', async () => { 12 | const data = await info('http://fakeimg.pl/1000x1000/') 13 | 14 | // This is to let the test pass if the server returns no Content-Length hader 15 | const size = data.size 16 | data.size = undefined 17 | 18 | deepStrictEqual(data, { 19 | width: 1000, 20 | height: 1000, 21 | type: 'png', 22 | time: data.time, 23 | size: undefined, 24 | analyzed: data.analyzed, 25 | realUrl: 'https://fakeimg.pl/1000x1000/' 26 | }) 27 | 28 | if (size) { 29 | deepStrictEqual(size, 17_308) 30 | ok(data.analyzed < size) 31 | } 32 | }) 33 | 34 | await test('should return a error when the host cannot be found', async () => { 35 | await rejects( 36 | info('https://fakeimg-no.pl/1000x1000/'), 37 | new FastImageError('Invalid remote host requested.', 'NETWORK_ERROR', 'https://fakeimg-no.pl/1000x1000/') 38 | ) 39 | }) 40 | 41 | await test('should return a error when the URL cannot be found', async () => { 42 | await rejects( 43 | info('https://fakeimg.pl/invalid'), 44 | new FastImageError('Remote host replied with HTTP 404.', 'NETWORK_ERROR', 'https://fakeimg.pl/invalid') 45 | ) 46 | }) 47 | 48 | await test('should return a error when the URL is not a image', async () => { 49 | await rejects(info('https://www.google.com/robots.txt'), new FastImageError('Unsupported data.', 'UNSUPPORTED')) 50 | }) 51 | 52 | await test('should return a error when the URL is not a image when downloading the entire file', async () => { 53 | await rejects( 54 | info('https://www.google.com/robots.txt', { threshold: 0 }), 55 | new FastImageError('Unsupported data.', 'UNSUPPORTED') 56 | ) 57 | }) 58 | 59 | await test('should handle connection timeouts', async () => { 60 | const server = createServer(c => {}) 61 | 62 | server.listen({ port: 0 }) 63 | 64 | const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}` 65 | 66 | await rejects( 67 | info(url, { timeout: 10 }), 68 | new FastImageError('Connection to the remote host timed out.', 'NETWORK_ERROR', url) 69 | ) 70 | 71 | server.close() 72 | }) 73 | 74 | await test('should handle connection failures', async () => { 75 | await rejects( 76 | info('http://127.0.0.1:65000/100x100'), 77 | new FastImageError( 78 | 'Connection refused from the remote host.', 79 | 'NETWORK_ERROR', 80 | 'http://127.0.0.1:65000/100x100' 81 | ) 82 | ) 83 | }) 84 | 85 | await test('should handle connection interruptions', async () => { 86 | const server = createServer(c => { 87 | c.end() 88 | }) 89 | 90 | server.listen({ port: 0 }) 91 | 92 | const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}` 93 | await rejects(info(url), new FastImageError('Connection with the remote host interrupted.', 'NETWORK_ERROR', url)) 94 | 95 | server.close() 96 | }) 97 | 98 | await test('should complain about invalid URLs.', async () => { 99 | await rejects( 100 | info('ftp://127.0.0.1:65000/100x100'), 101 | new FastImageError('Invalid URL.', 'URL_ERROR', 'ftp://127.0.0.1:65000/100x100') 102 | ) 103 | }) 104 | 105 | await test('should send the right user agent', async () => { 106 | const agents: string[] = [] 107 | 108 | const server = createHttpServer((r, s) => { 109 | agents.push(r.headers['user-agent']!) 110 | s.end(readFileSync(new URL('fixtures/image.png', import.meta.url).toString().replace('file://', ''))) 111 | }) 112 | 113 | server.listen({ port: 0 }) 114 | 115 | const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}` 116 | 117 | await info(url) 118 | await info(url, { userAgent: 'FOO' }) 119 | 120 | server.close() 121 | 122 | deepStrictEqual(agents, [`fastimage/${userAgentVersion}`, 'FOO']) 123 | }) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "jsx": "preserve", 7 | "declaration": true, 8 | "outDir": "dist", 9 | "allowJs": false, 10 | "allowSyntheticDefaultImports": true, 11 | "esModuleInterop": true, 12 | "strict": true, 13 | "skipLibCheck": true, 14 | "noImplicitAny": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "strictNullChecks": true, 18 | "useUnknownInCatchVariables": false 19 | }, 20 | "include": ["src/*.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/*.ts", "test/*.ts"] 4 | } 5 | --------------------------------------------------------------------------------