Latest Threat Research:SANDWORM_MODE: Shai-Hulud-Style npm Worm Hijacks CI Workflows and Poisons AI Toolchains.Details
Socket
Book a DemoInstallSign in
Socket

ora

Package Overview
Dependencies
Maintainers
1
Versions
55
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ora - npm Package Compare versions

Comparing version
9.0.0
to
9.1.0
+271
-97
index.js
import process from 'node:process';
import {stripVTControlCharacters} from 'node:util';
import chalk from 'chalk';

@@ -6,3 +7,2 @@ import cliCursor from 'cli-cursor';

import logSymbols from 'log-symbols';
import stripAnsi from 'strip-ansi';
import stringWidth from 'string-width';

@@ -13,9 +13,12 @@ import isInteractive from 'is-interactive';

// Constants
const RENDER_DEFERRAL_TIMEOUT = 200; // Milliseconds to wait before re-rendering after partial chunk write
// Global state for concurrent spinner detection
const activeHooksPerStream = new Map(); // Stream → ora instance
class Ora {
#linesToClear = 0;
#isDiscardingStdin = false;
#lineCount = 0;
#frameIndex = -1;
#lastSpinnerFrameTime = 0;
#lastIndent = 0;
#lastFrameTime = 0;
#options;

@@ -25,11 +28,89 @@ #spinner;

#id;
#initialInterval;
#isEnabled;
#isSilent;
#indent;
#text;
#prefixText;
#suffixText;
#hookedStreams = new Map();
#isInternalWrite = false;
#drainHandler;
#deferRenderTimer;
#isDiscardingStdin = false;
color;
// Helper to execute writes while preventing hook recursion
#internalWrite(fn) {
this.#isInternalWrite = true;
try {
return fn();
} finally {
this.#isInternalWrite = false;
}
}
// Helper to render if still spinning
#tryRender() {
if (this.isSpinning) {
this.render();
}
}
#stringifyChunk(chunk, encoding) {
if (chunk === undefined || chunk === null) {
return '';
}
if (typeof chunk === 'string') {
return chunk;
}
/* eslint-disable n/prefer-global/buffer */
if (Buffer.isBuffer(chunk) || ArrayBuffer.isView(chunk)) {
const normalizedEncoding = (typeof encoding === 'string' && encoding && encoding !== 'buffer') ? encoding : 'utf8';
return Buffer.from(chunk).toString(normalizedEncoding);
}
/* eslint-enable n/prefer-global/buffer */
return String(chunk);
}
#chunkTerminatesLine(chunkString) {
if (!chunkString) {
return false;
}
const lastCharacter = chunkString.at(-1);
return lastCharacter === '\n' || lastCharacter === '\r';
}
#scheduleRenderDeferral() {
// If already deferred, don't reset timer - let it complete
if (this.#deferRenderTimer) {
return;
}
this.#deferRenderTimer = setTimeout(() => {
this.#deferRenderTimer = undefined;
if (this.isSpinning) {
this.#tryRender();
}
}, RENDER_DEFERRAL_TIMEOUT);
if (typeof this.#deferRenderTimer?.unref === 'function') {
this.#deferRenderTimer.unref();
}
}
#clearRenderDeferral() {
if (this.#deferRenderTimer) {
clearTimeout(this.#deferRenderTimer);
this.#deferRenderTimer = undefined;
}
}
// Helper to build complete line with symbol, text, prefix, and suffix
#buildOutputLine(symbol, text, prefixText, suffixText) {
const fullPrefixText = this.#getFullPrefixText(prefixText, ' ');
const separatorText = symbol ? ' ' : '';
const fullText = (typeof text === 'string') ? separatorText + text : '';
const fullSuffixText = this.#getFullSuffixText(suffixText, ' ');
return fullPrefixText + symbol + fullText + fullSuffixText;
}
constructor(options) {

@@ -53,12 +134,19 @@ if (typeof options === 'string') {

// It's important that these use the public setters.
this.spinner = this.#options.spinner;
this.#initialInterval = this.#options.interval;
this.#stream = this.#options.stream;
this.#isEnabled = typeof this.#options.isEnabled === 'boolean' ? this.#options.isEnabled : isInteractive({stream: this.#stream});
this.#isSilent = typeof this.#options.isSilent === 'boolean' ? this.#options.isSilent : false;
// Normalize isEnabled and isSilent into options
if (typeof this.#options.isEnabled !== 'boolean') {
this.#options.isEnabled = isInteractive({stream: this.#stream});
}
if (typeof this.#options.isSilent !== 'boolean') {
this.#options.isSilent = false;
}
// Set *after* `this.#stream`.
// Store original interval before spinner setter clears it
const userInterval = this.#options.interval;
// It's important that these use the public setters.
this.spinner = this.#options.spinner;
this.#options.interval = userInterval;
this.text = this.#options.text;

@@ -71,3 +159,3 @@ this.prefixText = this.#options.prefixText;

this._stream = this.#stream;
this._isEnabled = this.#isEnabled;
this._isEnabled = this.#options.isEnabled;

@@ -91,3 +179,10 @@ Object.defineProperty(this, '_linesToClear', {

get() {
return this.#lineCount;
const columns = this.#stream.columns ?? 80;
const prefixText = typeof this.#options.prefixText === 'function' ? '' : this.#options.prefixText;
const suffixText = typeof this.#options.suffixText === 'function' ? '' : this.#options.suffixText;
const fullPrefixText = (typeof prefixText === 'string' && prefixText !== '') ? prefixText + ' ' : '';
const fullSuffixText = (typeof suffixText === 'string' && suffixText !== '') ? ' ' + suffixText : '';
const spinnerChar = '-';
const fullText = ' '.repeat(this.#options.indent) + fullPrefixText + spinnerChar + (typeof this.#options.text === 'string' ? ' ' + this.#options.text : '') + fullSuffixText;
return this.#computeLineCountFrom(fullText, columns);
},

@@ -99,3 +194,3 @@ });

get indent() {
return this.#indent;
return this.#options.indent;
}

@@ -108,8 +203,7 @@

this.#indent = indent;
this.#updateLineCount();
this.#options.indent = indent;
}
get interval() {
return this.#initialInterval ?? this.#spinner.interval ?? 100;
return this.#options.interval ?? this.#spinner.interval ?? 100;
}

@@ -123,3 +217,3 @@

this.#frameIndex = -1;
this.#initialInterval = undefined;
this.#options.interval = undefined;

@@ -149,26 +243,23 @@ if (typeof spinner === 'object') {

get text() {
return this.#text;
return this.#options.text;
}
set text(value = '') {
this.#text = value;
this.#updateLineCount();
this.#options.text = value;
}
get prefixText() {
return this.#prefixText;
return this.#options.prefixText;
}
set prefixText(value = '') {
this.#prefixText = value;
this.#updateLineCount();
this.#options.prefixText = value;
}
get suffixText() {
return this.#suffixText;
return this.#options.suffixText;
}
set suffixText(value = '') {
this.#suffixText = value;
this.#updateLineCount();
this.#options.suffixText = value;
}

@@ -189,7 +280,7 @@

#getFullPrefixText(prefixText = this.#prefixText, postfix = ' ') {
#getFullPrefixText(prefixText = this.#options.prefixText, postfix = ' ') {
return this.#formatAffix(prefixText, postfix, false);
}
#getFullSuffixText(suffixText = this.#suffixText, prefix = ' ') {
#getFullSuffixText(suffixText = this.#options.suffixText, prefix = ' ') {
return this.#formatAffix(suffixText, prefix, true);

@@ -200,3 +291,3 @@ }

let count = 0;
for (const line of stripAnsi(text).split('\n')) {
for (const line of stripVTControlCharacters(text).split('\n')) {
count += Math.max(1, Math.ceil(stringWidth(line) / columns));

@@ -208,18 +299,4 @@ }

#updateLineCount() {
const columns = this.#stream.columns ?? 80;
// Simple side-effect free approximation (do not call functions)
const prefixText = typeof this.#prefixText === 'function' ? '' : this.#prefixText;
const suffixText = typeof this.#suffixText === 'function' ? '' : this.#suffixText;
const fullPrefixText = (typeof prefixText === 'string' && prefixText !== '') ? prefixText + ' ' : '';
const fullSuffixText = (typeof suffixText === 'string' && suffixText !== '') ? ' ' + suffixText : '';
const spinnerChar = '-';
const fullText = ' '.repeat(this.#indent) + fullPrefixText + spinnerChar + (typeof this.#text === 'string' ? ' ' + this.#text : '') + fullSuffixText;
this.#lineCount = this.#computeLineCountFrom(fullText, columns);
}
get isEnabled() {
return this.#isEnabled && !this.#isSilent;
return this.#options.isEnabled && !this.#options.isSilent;
}

@@ -232,7 +309,7 @@

this.#isEnabled = value;
this.#options.isEnabled = value;
}
get isSilent() {
return this.#isSilent;
return this.#options.isSilent;
}

@@ -245,12 +322,11 @@

this.#isSilent = value;
this.#options.isSilent = value;
}
frame() {
// Ensure we only update the spinner frame at the wanted interval,
// even if the render method is called more often.
// Only advance frame if enough time has passed (throttle to interval)
const now = Date.now();
if (this.#frameIndex === -1 || now - this.#lastSpinnerFrameTime >= this.interval) {
this.#frameIndex = ++this.#frameIndex % this.#spinner.frames.length;
this.#lastSpinnerFrameTime = now;
if (this.#frameIndex === -1 || now - this.#lastFrameTime >= this.interval) {
this.#frameIndex = (this.#frameIndex + 1) % this.#spinner.frames.length;
this.#lastFrameTime = now;
}

@@ -265,5 +341,5 @@

const fullPrefixText = this.#getFullPrefixText(this.#prefixText, ' ');
const fullPrefixText = this.#getFullPrefixText(this.#options.prefixText, ' ');
const fullText = typeof this.text === 'string' ? ' ' + this.text : '';
const fullSuffixText = this.#getFullSuffixText(this.#suffixText, ' ');
const fullSuffixText = this.#getFullSuffixText(this.#options.suffixText, ' ');

@@ -274,28 +350,110 @@ return fullPrefixText + frame + fullText + fullSuffixText;

clear() {
if (!this.#isEnabled || !this.#stream.isTTY) {
if (!this.isEnabled || !this.#stream.isTTY) {
return this;
}
this.#stream.cursorTo(0);
// Protect cursor control methods (cursorTo, moveCursor, clearLine) which internally call stream.write
this.#internalWrite(() => {
this.#stream.cursorTo(0);
for (let index = 0; index < this.#linesToClear; index++) {
if (index > 0) {
this.#stream.moveCursor(0, -1);
for (let index = 0; index < this.#linesToClear; index++) {
if (index > 0) {
this.#stream.moveCursor(0, -1);
}
this.#stream.clearLine(1);
}
this.#stream.clearLine(1);
if (this.#options.indent) {
this.#stream.cursorTo(this.#options.indent);
}
});
this.#linesToClear = 0;
return this;
}
// Helper to hook a single stream
#hookStream(stream) {
if (!stream || this.#hookedStreams.has(stream) || !stream.isTTY || typeof stream.write !== 'function') {
return;
}
if (this.#indent || this.#lastIndent !== this.#indent) {
this.#stream.cursorTo(this.#indent);
// Detect concurrent spinners
if (activeHooksPerStream.has(stream)) {
console.warn('[ora] Multiple concurrent spinners detected. This may cause visual corruption. Use one spinner at a time.');
}
this.#lastIndent = this.#indent;
this.#linesToClear = 0;
const originalWrite = stream.write;
this.#hookedStreams.set(stream, originalWrite);
activeHooksPerStream.set(stream, this);
stream.write = (chunk, encoding, callback) => this.#hookedWrite(stream, originalWrite, chunk, encoding, callback);
}
return this;
/**
Intercept stream writes while spinner is active to handle external writes cleanly without visual corruption.
Hooks process stdio streams and the active spinner stream so console.log(), console.error(), and direct writes stay tidy.
*/
#installHook() {
if (!this.isEnabled || this.#hookedStreams.size > 0) {
return;
}
const streamsToHook = new Set([this.#stream, process.stdout, process.stderr]);
for (const stream of streamsToHook) {
this.#hookStream(stream);
}
}
#uninstallHook() {
for (const [stream, originalWrite] of this.#hookedStreams) {
stream.write = originalWrite;
if (activeHooksPerStream.get(stream) === this) {
activeHooksPerStream.delete(stream);
}
}
this.#hookedStreams.clear();
}
// eslint-disable-next-line max-params -- Need stream and originalWrite for multi-stream support
#hookedWrite(stream, originalWrite, chunk, encoding, callback) {
// Handle both write(chunk, encoding, callback) and write(chunk, callback) signatures
if (typeof encoding === 'function') {
callback = encoding;
encoding = undefined;
}
// Pass through our own internal writes (spinner rendering, cursor control)
if (this.#isInternalWrite) {
return originalWrite.call(stream, chunk, encoding, callback);
}
// External write detected - clear spinner, write content, re-render if appropriate
this.clear();
const chunkString = this.#stringifyChunk(chunk, encoding);
const chunkTerminatesLine = this.#chunkTerminatesLine(chunkString);
const writeResult = originalWrite.call(stream, chunk, encoding, callback);
// Schedule or clear render deferral based on chunk content
if (chunkTerminatesLine) {
this.#clearRenderDeferral();
} else if (chunkString.length > 0) {
this.#scheduleRenderDeferral();
}
// Re-render spinner below the new output if still spinning and not deferred
if (this.isSpinning && !this.#deferRenderTimer) {
this.render();
}
return writeResult;
}
render() {
if (!this.#isEnabled || this.#isSilent) {
if (!this.isEnabled || this.#drainHandler || this.#deferRenderTimer) {
return this;

@@ -318,3 +476,14 @@ }

this.#stream.write(frameContent);
const canContinue = this.#internalWrite(() => this.#stream.write(frameContent));
// Handle backpressure - pause rendering if stream buffer is full
if (canContinue === false && this.#stream.isTTY) {
this.#drainHandler = () => {
this.#drainHandler = undefined;
this.#tryRender();
};
this.#stream.once('drain', this.#drainHandler);
}
this.#linesToClear = this.#computeLineCountFrom(frameContent, columns);

@@ -330,11 +499,12 @@

if (this.#isSilent) {
if (this.isSilent) {
return this;
}
if (!this.#isEnabled) {
const line = ' '.repeat(this.#indent) + this.#getFullPrefixText(this.#prefixText, ' ') + (this.text ? `- ${this.text}` : '') + this.#getFullSuffixText(this.#suffixText, ' ');
if (!this.isEnabled) {
const symbol = this.text ? '-' : '';
const line = ' '.repeat(this.#options.indent) + this.#buildOutputLine(symbol, this.text, this.#options.prefixText, this.#options.suffixText);
if (line.trim() !== '') {
this.#stream.write(line + '\n');
this.#internalWrite(() => this.#stream.write(line + '\n'));
}

@@ -354,6 +524,7 @@

if (this.#options.discardStdin && process.stdin.isTTY) {
stdinDiscarder.start();
this.#isDiscardingStdin = true;
stdinDiscarder.start();
}
this.#installHook();
this.render();

@@ -368,5 +539,15 @@ this.#id = setInterval(this.render.bind(this), this.interval);

this.#id = undefined;
this.#frameIndex = 0;
this.#frameIndex = -1;
this.#lastFrameTime = 0;
if (this.#isEnabled) {
this.#clearRenderDeferral();
this.#uninstallHook();
// Clean up drain handler if it exists
if (this.#drainHandler) {
this.#stream.removeListener('drain', this.#drainHandler);
this.#drainHandler = undefined;
}
if (this.isEnabled) {
this.clear();

@@ -378,5 +559,5 @@ if (this.#options.hideCursor) {

if (this.#options.discardStdin && process.stdin.isTTY && this.#isDiscardingStdin) {
if (this.#isDiscardingStdin) {
this.#isDiscardingStdin = false;
stdinDiscarder.stop();
this.#isDiscardingStdin = false;
}

@@ -404,22 +585,15 @@

stopAndPersist(options = {}) {
if (this.#isSilent) {
if (this.isSilent) {
return this;
}
const prefixText = options.prefixText ?? this.#prefixText;
const fullPrefixText = this.#getFullPrefixText(prefixText, ' ');
const symbolText = options.symbol ?? ' ';
const symbol = options.symbol ?? ' ';
const text = options.text ?? this.text;
const separatorText = symbolText ? ' ' : '';
const fullText = (typeof text === 'string') ? separatorText + text : '';
const prefixText = options.prefixText ?? this.#options.prefixText;
const suffixText = options.suffixText ?? this.#options.suffixText;
const suffixText = options.suffixText ?? this.#suffixText;
const fullSuffixText = this.#getFullSuffixText(suffixText, ' ');
const textToWrite = this.#buildOutputLine(symbol, text, prefixText, suffixText) + '\n';
const textToWrite = fullPrefixText + symbolText + fullText + fullSuffixText + '\n';
this.stop();
this.#stream.write(textToWrite);
this.#internalWrite(() => this.#stream.write(textToWrite));

@@ -426,0 +600,0 @@ return this;

{
"name": "ora",
"version": "9.0.0",
"version": "9.1.0",
"description": "Elegant terminal spinner",

@@ -53,4 +53,3 @@ "license": "MIT",

"stdin-discarder": "^0.2.2",
"string-width": "^8.1.0",
"strip-ansi": "^7.1.2"
"string-width": "^8.1.0"
},

@@ -57,0 +56,0 @@ "devDependencies": {

@@ -315,2 +315,20 @@ # ora

### Can I log messages while the spinner is running?
Yes! Ora automatically handles writes to the same stream. The spinner will temporarily clear itself, output your message, and re-render below:
```js
const spinner = ora('Processing...').start();
console.log('Step 1 complete');
console.log('Step 2 complete');
spinner.succeed('Done!');
```
The output will be clean with each log appearing above the spinner. This works seamlessly without requiring any special logging methods. Both `console.log()` (stdout) and `console.error()`/`console.warn()` (stderr) are supported.
> [!NOTE]
> Don't run multiple spinners concurrently. Use one spinner at a time.
### Can I display multiple spinners simultaneously?

@@ -317,0 +335,0 @@