index.js

/**
 * Main entry of `temppath` module.
 * @module temppath
 * @author Ryuu Mitsuki
 */

'use strict';

const fs = require('node:fs');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
// Should be avoid due to deprecated
// const { isNullOrUndefined } = require('node:util');


/**
 * Callback function to handle the result or error from calling the
 * {@link module:temppath~createTempPath|createTempPath} function.
 *
 * @callback CreateTempPathCallback
 * @param {?Error} error - An error object if an error occurred, or `null` if no error.
 * @param {string} resultPath - The path of the created temporary directory or file.
 * @global
 * @since 0.2.0
 */

/**
 * An object representating options to configure the tempoarary file or directory
 * creation within this module.
 *
 * @typedef  {Object}  TempPathOptions
 * @property {boolean} [asFile=false] - Whether to create a temporary file instead directory.
 * @property {string}  [ext='.tmp'] - An extension name to use for tempoarary file creation.
 *                                    Ignored if the `asFile` is set to `false`.
 * @property {number}  [maxLen=32] - The maximum characters' length of the generated temporary
 *                                   directory or file name.
 * @global
 * @since  0.3.0
 */


// Alternative for `node:util.isNullOrUndefined`
function isNullOrUndefined(o) {
  return (o === null || typeof o === 'undefined');
}

/**
 * Retrieves the temporary directory path based on the current environment.
 *
 * This function first checks common environment variables for temporary directories:
 * - `TMPDIR` on UNIX-like and macOS systems
 * - `TMP` or `TEMP` on Windows systems
 * 
 * If none of these environment variables are set or return null, it falls back to the 
 * `os.tmpdir()` function. If this also returns null, it uses the current working directory 
 * and creates a new directory called `tmp`.
 *
 * @private
 * @function
 * @returns {string} The temporary directory path.
 * @since 0.1.0
 */
function __getTempDir() {
  return process.env.TMPDIR                   // Unix-like & MacOS systems
    || (process.env.TMP || process.env.TEMP)  // Windows system
    || require('node:os').tmpdir()            // Fallback
    || path.resolve(process.cwd(), 'tmp');    // Otherwise, use current directory
}


/**
 * Generates a temporary path based on the provided or system temporary directory.
 * 
 * This function utilizes a random UUID for the directory name, ensuring that each time
 * it is called, the path will be different from previous calls. The returned path can be
 * used for either a temporary file or directory, according to user preferences.
 *
 * To limit the characters' length of generated temporary directory or file name,
 * set the `maxLen` to the desired value and it must be a positive number.
 * Thus, the function will trim the name to the desired maximum length.
 *
 * @public
 * @function
 * @param {string} [tmpdir] - The temporary directory path. If not provided or empty,
 *                            it defaults to the system's temporary directory.
 * @param {number} [maxLen=32] - The maximum characters' length of the generated temporary path.
 *                            Must be a positive number and greater than zero.
 *
 * @returns {string} The generated temporary path.
 *
 * @throws {TypeError} Throws a `TypeError` if the provided `tmpdir` is not a string.
 * @throws {RangeError} If the given `maxLen` is less than or equal to zero.
 *
 * @since 0.1.0
 */
function getTempPath(tmpdir, maxLen) {
  if (tmpdir && typeof tmpdir !== 'string') {
    throw new TypeError(`Expected type is string. Received ${typeof tmpdir}`);
  }
  if (maxLen <= 0) {
    throw new RangeError('Maximum characters must be greater than zero');
  }

  return path.join(
    (isNullOrUndefined(tmpdir) || tmpdir.length === 0) ? __getTempDir() : tmpdir,
    randomUUID().replace(/-/g, '').substr(0, maxLen)
  );
}

/**
 * Asynchronously creates a temporary path, either as a directory or file,
 * based on the provided or system temporary directory.
 *
 * This function utilizes the {@link module:temppath~getTempPath|getTempPath} function.
 * To limit the characters' length of generated temporary directory or file name,
 * set the {@link TempPathOptions.maxLen `options.maxLen`} to the desired value and
 * it must be a positive number. Thus, the function will trim the name to the desired
 * maximum length.
 *
 * @public
 * @async
 * @function
 * @param {string | TempPathOptions | CreateTempPathCallback} [tmpdir] - The temporary directory path.
 *                                   If an object is provided, it is treated as the `options` parameter.
 *                                   If a function is provided, it is treated as the `callback` parameter,
 *                                   and `tmpdir` will fallback to system-based temporary directory.
 * @param {TempPathOptions | CreateTempPathCallback} [options] - Options for creating the temporary path.
 *                                   If a function is provided, it is treated as the `callback` parameter,
 *                                   and `options` is set to `{}` (an empty object).
 * @param {boolean} [options.asFile=false] - If `true`, create a temporary file. Otherwise, create a directory.
 * @param {string} [options.ext='.tmp'] - The extension for the temporary file. If `asFile` option is `false`,
 *                                        this option will be ignored. Default is '.tmp'.
 * @param {number} [options.maxLen=32] - The maximum characters' length of the generated directory or file name.
 *                                       Defaults to 32 characters.
 * @param {CreateTempPathCallback} callback - A callback function to handle the result path or error.
 *                                            This is crucial and required, even when you wanted to omit all arguments.
 *
 * @throws {TypeError} If the given arguments or the extension name specified with incorrect type.
 *
 * @example <caption>Only specify a callback function</caption>
 * // Without any argument but a callback function
 * // This will create a temporary directory in system's temporary path
 * createTempPath(function (err, createdPath) {
 *   if (err) console.error(err);
 *   else console.log(createdPath);
 *   // Unix: "$TMPDIR/<TEMPPATH_DIR>"
 *   // Termux Android: "$TMPDIR/<TEMPPATH_DIR>" or "$PREFIX/tmp/<TEMPPATH_DIR>"
 *   // Windows: "%TMP%\<TEMPPATH_DIR>" or "%TEMP%\<TEMPPATH_DIR>"
 * });
 *
 * @example <caption>Create a temporary file in system's temporary path</caption>
 * // This will create a temporary file in system's temporary path
 * // with extension name of '.foo_temp'
 * createTempPath({ asFile: true, ext: 'foo_temp' }, function (err, createdPath) {
 *   if (err) console.error(err);
 *   else console.log(createdPath);
 *   // Unix: "$TMPDIR/<TEMPPATH_FILE>.foo_temp"
 *   // Termux Android: "$TMPDIR/<TEMPPATH_FILE>.foo_temp" or "$PREFIX/tmp/<TEMPPATH_FILE>.foo_temp"
 *   // Windows: "%TMP%\<TEMPPATH_FILE>.foo_temp" or "%TEMP%\<TEMPPATH_FILE>.foo_temp"
 * });
 *
 * @example <caption>Create a temporary file in current directory</caption>
 * const path = require('node:path');
 * // For ESM: import path from 'node:path';
 *
 * // Use `process.cwd()` to get current directory path
 * createTempPath(path.join(process.cwd(), 'tmp'), { asFile: true }, function (err, createdPath) {
 *   if (err) console.error(err);
 *   else console.log(createdPath);
 *   // Unix: "$PWD/tmp/<TEMPPATH_FILE>.tmp"
 *   // Termux Android: "$PWD/tmp/<TEMPPATH_FILE>.tmp"
 *   // Windows: "%CD%\tmp\<TEMPPATH_FILE>.tmp"
 * });
 *
 * @since 0.2.0
 */
function createTempPath(tmpdir, options, callback) {
  if ((typeof tmpdir === 'object' && !Array.isArray(tmpdir))
      && typeof options === 'function' && isNullOrUndefined(callback)) {
    callback = options;  // Swap the `options` to `callback`
    options = tmpdir;    // Swap the `tmpdir` to `options`
    tmpdir = null;       // Keep this empty
  } else if (!isNullOrUndefined(tmpdir) && typeof options === 'function'
      && isNullOrUndefined(callback)) {
    callback = options;
    options = {};
  } else if (typeof tmpdir === 'function' && isNullOrUndefined(options)
      && isNullOrUndefined(callback)) {
    callback = tmpdir;
    options = {};
    tmpdir = null;
  } else if (isNullOrUndefined(options)) {
    options = {};
  }

  if (!callback || typeof callback !== 'function') {
    throw new TypeError(
      `The "callback" argument must be a function. Received ${typeof callback}`);
  }
  if (options && typeof options !== 'object') {
    throw new TypeError(
      `The "options" argument must be an object. Received ${typeof options}`);
  }
  if (options.ext && typeof options.ext !== 'string') {
    throw new TypeError(`Expected a string extension, got ${typeof options.ext}`);
  }
  if (options.maxLen && typeof options.maxLen !== 'number') {
    throw new TypeError(`Expected a number for maxLen, got ${typeof options.maxLen}`);
  }

  // Resolve the temporary path
  tmpdir = getTempPath(tmpdir, options.maxLen);

  const extension = (options.ext)
    ? options.ext.startsWith('.')
      ? options.ext
      : `.${options.ext}`
    // Use default extension, if the extension name is not specified
    // * feat: Add support for temporary file creation with no extension
    : ((typeof options.ext === 'string' && options.ext.length === 0)
      ? options.ext
      : '.tmp'
    );

  // Create the parent directory of generated temporary path
  fs.promises.mkdir(path.dirname(tmpdir), { recursive: true })
    .then(function () {
      if (options.asFile) {
        const filename = tmpdir + extension;
        // Create an empty file in the temporary directory
        fs.promises.writeFile(filename, '')
          .then(() => callback(null, filename))  // Return the created the temporary file path
          .catch(err => callback(err));
      } else {
        // Create an empty directory in the temporary directory
        fs.promises.mkdir(tmpdir)
          .then(() => callback(null, tmpdir))  // Return the created temporary directory path
          .catch(err => callback(err));
      }
    })
    .catch(err => callback(err));
}

/**
 * Synchronously creates a temporary path, either as a directory or file, based on the provided
 * or system temporary directory and then returns a path that refers to the generated temporary directory or file.
 *
 * This function utilizes the {@link module:temppath~getTempPath|getTempPath} function.
 * To limit the characters' length of generated temporary directory or file name,
 * set the {@link TempPathOptions.maxLen `options.maxLen`} to the desired value and
 * it must be a positive number. Thus, the function will trim the name to the desired
 * maximum length.
 *
 * @public
 * @function
 * @param {string | TempPathOptions} [tmpdir] - The temporary directory path. If an object is provided,
 *                                   it is treated as the `options` parameter,
 *                                   and `tmpdir` will fallback to system-based temporary directory.
 * @param {Object} [options] - Options for creating the temporary path.
 * @param {boolean} [options.asFile=false] - If `true`, create a temporary file. Otherwise, create a directory.
 * @param {string} [options.ext='.tmp'] - The extension for the temporary file. If `asFile` option is `false`,
 *                                        this option will be ignored. Default is `'.tmp'`.
 * @param {number} [options.maxLen=32] - The maximum characters' length of the generated directory or file name.
 *                                       Defaults to 32 characters.
 *
 * @returns {string} The path of the created temporary directory or file.
 *
 * @throws {TypeError} If the given arguments or the extension name specified with incorrect type.
 * @throws {Error} Throws an `Error` if there is an issue creating the temporary directory or file.
 *
 * @example <caption>Call the function without any argument</caption>
 * // If no argument specified, it will creates a temporary directory
 * // in system's temporary path
 * const tmpDirPath = createTempPathSync();
 * console.log(tmpDirPath);
 * // Unix: "$TMPDIR/<TEMPPATH_DIR>"
 * // Termux Android: "$TMPDIR/<TEMPPATH_DIR>" or "$PREFIX/tmp/<TEMPPATH_DIR>"
 * // Windows: "%TMP%\<TEMPPATH_DIR>" or "%TEMP%\<TEMPPATH_DIR>"
 *
 * @example <caption>Create a temporary file with custom extension</caption>
 * const tmpFilePath = createTempPathSync({ asFile: true, ext: 'txt' });
 * console.log(tmpFilePath);
 * // Unix: "$TMPDIR/<TEMPPATH_FILE>.txt"
 * // Termux Android: "$TMPDIR/<TEMPPATH_FILE>.txt" or "$PREFIX/tmp/<TEMPPATH_FILE>.txt"
 * // Windows: "%TMP%\<TEMPPATH_FILE>.txt" or "%TEMP%\<TEMPPATH_FILE>.txt"
 *
 * @example <caption>Create a temporary file in current directory</caption>
 * const path = require('node:path');
 * // For ESM: import path from 'node:path';
 *
 * // Use `process.cwd()` to get current directory path
 * const customTmpFilePath = createTempPathSync(
 *   path.join(process.cwd(), 'tmp'), { asFile: true });
 * console.log(customTmpFilePath);
 * // Unix: "$PWD/tmp/<TEMPPATH_FILE>.tmp"
 * // Termux Android: "$PWD/tmp/<TEMPPATH_FILE>.tmp"
 * // Windows: "%CD%\tmp\<TEMPPATH_FILE>.tmp"
 *
 * @since 0.2.0
 */
function createTempPathSync(tmpdir, options) {
  // Swap the 'tmpdir' argument to 'options', if the provided is an object
  // and the 'options' argument is undefined or empty
  if (typeof tmpdir === 'object' && !Array.isArray(tmpdir)
          && isNullOrUndefined(options)) {
      options = tmpdir;  // Swap
      tmpdir = null;     // Make this empty
  } else if (isNullOrUndefined(options)) {
      // By using this approach, all paramaters will be optional.
      // Users can simply call this function without any argument and with no error.
      options = {};
  }

  if (typeof options !== 'object') {
    throw new TypeError(
      `The "options" argument must be an object. Received ${typeof options}`);
  }
  if (options.ext && typeof options.ext !== 'string') {
    throw new TypeError(`Expected a string extension, got ${typeof options.ext}`);
  }
  if (options.maxLen && typeof options.maxLen !== 'number') {
    throw new TypeError(`Expected a number for maxLen, got ${typeof options.maxLen}`);
  }

  // Resolve the temporary path
  tmpdir = getTempPath(tmpdir, options.maxLen);

  const extension = (options.ext)
    ? options.ext.startsWith('.')
      ? options.ext
      : `.${options.ext}`
    // Use default extension, if the extension name is not specified
    // * feat: Add support for temporary file creation with no extension
    : ((typeof options.ext === 'string' && options.ext.length === 0)
      ? options.ext
      : '.tmp'
    );

  try {
    // Create the parent directory of generated temporary path
    fs.mkdirSync(path.dirname(tmpdir), { recursive: true });

    if (options.asFile) {
      const filename = tmpdir + extension;
      fs.writeFileSync(filename, '');  // Create an empty temporary file
      return filename;  // Return the created temporary file path
    }

    // Create a temporary directory with synchronous operation
    fs.mkdirSync(tmpdir);
    return tmpdir;  // Return the created temporary directory path
  } catch (err) {
    // Throw a new error with source error as causative error
    throw new Error(
      `temppath: Failed to create temporary ${
        (options.asFile) ? 'file' : 'directory'
      }.`, { cause: err }
    );
  }
}


/*----------
 * EXPORTS
 ----------*/

// Create a frozen object used for exports
const temppath = Object.freeze({
    getTempPath,
    createTempPath,
    createTempPathSync
});

// For CommonJS
if (!isNullOrUndefined(module)) {
    Object.defineProperty(module, 'exports', {
        value: Object.isFrozen(temppath) ? temppath : Object.freeze(temppath),
        writable: false
    });
}

// For ESModule
if (!isNullOrUndefined(exports) && typeof exports === 'object') {
    // Export the 'temppath' as default export
    Object.defineProperty(exports, 'default', {
        value: Object.isFrozen(temppath) ? temppath : Object.freeze(temppath),
        writable: false
    });
    
    exports.getTempPath = getTempPath;
    exports.createTempPath = createTempPath;
    exports.createTempPathSync = createTempPathSync;
}