repl: extract and standardize history from both repl and interface

PR-URL: https://github.com/nodejs/node/pull/58225
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Dario Piotrowicz <dario.piotrowicz@gmail.com>
This commit is contained in:
Giovanni Bucci 2025-05-28 00:38:39 -07:00 committed by GitHub
parent 4be147351a
commit 5a23443104
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 766 additions and 286 deletions

View File

@ -645,14 +645,35 @@ buffered but not yet executed. This method is primarily intended to be
called from within the action function for commands registered using the
`replServer.defineCommand()` method.
### `replServer.setupHistory(historyPath, callback)`
### `replServer.setupHistory(historyConfig, callback)`
<!-- YAML
added: v11.10.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/58225
description: Updated the `historyConfig` parameter to accept an object
with `filePath`, `size`, `removeHistoryDuplicates` and
`onHistoryFileLoaded` properties.
-->
* `historyPath` {string} the path to the history file
* `historyConfig` {Object|string} the path to the history file
If it is a string, it is the path to the history file.
If it is an object, it can have the following properties:
* `filePath` {string} the path to the history file
* `size` {number} Maximum number of history lines retained. To disable
the history set this value to `0`. This option makes sense only if
`terminal` is set to `true` by the user or by an internal `output` check,
otherwise the history caching mechanism is not initialized at all.
**Default:** `30`.
* `removeHistoryDuplicates` {boolean} If `true`, when a new input line added
to the history list duplicates an older one, this removes the older line
from the list. **Default:** `false`.
* `onHistoryFileLoaded` {Function} called when history writes are ready or upon error
* `err` {Error}
* `repl` {repl.REPLServer}
* `callback` {Function} called when history writes are ready or upon error
(Optional if provided as `onHistoryFileLoaded` in `historyConfig`)
* `err` {Error}
* `repl` {repl.REPLServer}

View File

@ -44,7 +44,7 @@ if (process.env.NODE_REPL_EXTERNAL_MODULE) {
throw err;
}
repl.on('exit', () => {
if (repl._flushing) {
if (repl.historyManager.isFlushing) {
return repl.once('flushHistory', () => {
process.exit();
});

View File

@ -3,14 +3,12 @@
const {
ArrayFrom,
ArrayPrototypeFilter,
ArrayPrototypeIndexOf,
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypePop,
ArrayPrototypePush,
ArrayPrototypeReverse,
ArrayPrototypeShift,
ArrayPrototypeSplice,
ArrayPrototypeUnshift,
DateNow,
FunctionPrototypeCall,
@ -19,6 +17,7 @@ const {
MathMax,
MathMaxApply,
NumberIsFinite,
ObjectDefineProperty,
ObjectSetPrototypeOf,
RegExpPrototypeExec,
SafeStringIterator,
@ -30,7 +29,6 @@ const {
StringPrototypeSlice,
StringPrototypeSplit,
StringPrototypeStartsWith,
StringPrototypeTrim,
Symbol,
SymbolAsyncIterator,
SymbolDispose,
@ -46,8 +44,6 @@ const {
const {
validateAbortSignal,
validateArray,
validateNumber,
validateString,
validateUint32,
} = require('internal/validators');
@ -67,7 +63,6 @@ const {
charLengthLeft,
commonPrefix,
kSubstringSearch,
reverseString,
} = require('internal/readline/utils');
let emitKeypressEvents;
let kFirstEventParam;
@ -78,8 +73,8 @@ const {
} = require('internal/readline/callbacks');
const { StringDecoder } = require('string_decoder');
const { ReplHistory } = require('internal/repl/history');
const kHistorySize = 30;
const kMaxUndoRedoStackSize = 2048;
const kMincrlfDelay = 100;
/**
@ -153,7 +148,6 @@ const kWriteToOutput = Symbol('_writeToOutput');
const kYank = Symbol('_yank');
const kYanking = Symbol('_yanking');
const kYankPop = Symbol('_yankPop');
const kNormalizeHistoryLineEndings = Symbol('_normalizeHistoryLineEndings');
const kSavePreviousState = Symbol('_savePreviousState');
const kRestorePreviousState = Symbol('_restorePreviousState');
const kPreviousLine = Symbol('_previousLine');
@ -175,9 +169,6 @@ function InterfaceConstructor(input, output, completer, terminal) {
FunctionPrototypeCall(EventEmitter, this);
let history;
let historySize;
let removeHistoryDuplicates = false;
let crlfDelay;
let prompt = '> ';
let signal;
@ -187,14 +178,17 @@ function InterfaceConstructor(input, output, completer, terminal) {
output = input.output;
completer = input.completer;
terminal = input.terminal;
history = input.history;
historySize = input.historySize;
signal = input.signal;
// It is possible to configure the history through the input object
const historySize = input.historySize;
const history = input.history;
const removeHistoryDuplicates = input.removeHistoryDuplicates;
if (input.tabSize !== undefined) {
validateUint32(input.tabSize, 'tabSize', true);
this.tabSize = input.tabSize;
}
removeHistoryDuplicates = input.removeHistoryDuplicates;
if (input.prompt !== undefined) {
prompt = input.prompt;
}
@ -215,24 +209,18 @@ function InterfaceConstructor(input, output, completer, terminal) {
crlfDelay = input.crlfDelay;
input = input.input;
input.size = historySize;
input.history = history;
input.removeHistoryDuplicates = removeHistoryDuplicates;
}
this.setupHistoryManager(input);
if (completer !== undefined && typeof completer !== 'function') {
throw new ERR_INVALID_ARG_VALUE('completer', completer);
}
if (history === undefined) {
history = [];
} else {
validateArray(history, 'history');
}
if (historySize === undefined) {
historySize = kHistorySize;
}
validateNumber(historySize, 'historySize', 0);
// Backwards compat; check the isTTY prop of the output stream
// when `terminal` was not specified
if (terminal === undefined && !(output === null || output === undefined)) {
@ -248,8 +236,6 @@ function InterfaceConstructor(input, output, completer, terminal) {
this.input = input;
this[kUndoStack] = [];
this[kRedoStack] = [];
this.history = history;
this.historySize = historySize;
this[kPreviousCursorCols] = -1;
// The kill ring is a global list of blocks of text that were previously
@ -260,7 +246,6 @@ function InterfaceConstructor(input, output, completer, terminal) {
this[kKillRing] = [];
this[kKillRingCursor] = 0;
this.removeHistoryDuplicates = !!removeHistoryDuplicates;
this.crlfDelay = crlfDelay ?
MathMax(kMincrlfDelay, crlfDelay) :
kMincrlfDelay;
@ -270,7 +255,6 @@ function InterfaceConstructor(input, output, completer, terminal) {
this.terminal = !!terminal;
function onerror(err) {
self.emit('error', err);
}
@ -349,8 +333,6 @@ function InterfaceConstructor(input, output, completer, terminal) {
// Cursor position on the line.
this.cursor = 0;
this.historyIndex = -1;
if (output !== null && output !== undefined)
output.on('resize', onresize);
@ -403,6 +385,36 @@ class Interface extends InterfaceConstructor {
return this[kPrompt];
}
setupHistoryManager(options) {
this.historyManager = new ReplHistory(this, options);
if (options.onHistoryFileLoaded) {
this.historyManager.initialize(options.onHistoryFileLoaded);
}
ObjectDefineProperty(this, 'history', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.history; },
set(newHistory) { return this.historyManager.history = newHistory; },
});
ObjectDefineProperty(this, 'historyIndex', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.index; },
set(historyIndex) { return this.historyManager.index = historyIndex; },
});
ObjectDefineProperty(this, 'historySize', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.size; },
});
ObjectDefineProperty(this, 'isFlushing', {
__proto__: null, configurable: true, enumerable: true,
get() { return this.historyManager.isFlushing; },
});
}
[kSetRawMode](mode) {
const wasInRawMode = this.input.isRaw;
@ -478,70 +490,8 @@ class Interface extends InterfaceConstructor {
}
}
// Convert newlines to a consistent format for history storage
[kNormalizeHistoryLineEndings](line, from, to, reverse = true) {
// Multiline history entries are saved reversed
// History is structured with the newest entries at the top
// and the oldest at the bottom. Multiline histories, however, only occupy
// one line in the history file. When loading multiline history with
// an old node binary, the history will be saved in the old format.
// This is why we need to reverse the multilines.
// Reversing the multilines is necessary when adding / editing and displaying them
if (reverse) {
// First reverse the lines for proper order, then convert separators
return reverseString(line, from, to);
}
// For normal cases (saving to history or non-multiline entries)
return StringPrototypeReplaceAll(line, from, to);
}
[kAddHistory]() {
if (this.line.length === 0) return '';
// If the history is disabled then return the line
if (this.historySize === 0) return this.line;
// If the trimmed line is empty then return the line
if (StringPrototypeTrim(this.line).length === 0) return this.line;
// This is necessary because each line would be saved in the history while creating
// A new multiline, and we don't want that.
if (this[kIsMultiline] && this.historyIndex === -1) {
ArrayPrototypeShift(this.history);
} else if (this[kLastCommandErrored]) {
// If the last command errored and we are trying to edit the history to fix it
// Remove the broken one from the history
ArrayPrototypeShift(this.history);
}
const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r', true);
if (this.history.length === 0 || this.history[0] !== normalizedLine) {
if (this.removeHistoryDuplicates) {
// Remove older history line if identical to new one
const dupIndex = ArrayPrototypeIndexOf(this.history, this.line);
if (dupIndex !== -1) ArrayPrototypeSplice(this.history, dupIndex, 1);
}
// Add the new line to the history
ArrayPrototypeUnshift(this.history, normalizedLine);
// Only store so many
if (this.history.length > this.historySize)
ArrayPrototypePop(this.history);
}
this.historyIndex = -1;
// The listener could change the history object, possibly
// to remove the last added entry if it is sensitive and should
// not be persisted in the history, like a password
const line = this[kIsMultiline] ? reverseString(this.history[0]) : this.history[0];
// Emit history event to notify listeners of update
this.emit('history', this.history);
return line;
return this.historyManager.addHistory(this[kIsMultiline], this[kLastCommandErrored]);
}
[kRefreshLine]() {
@ -1184,26 +1134,12 @@ class Interface extends InterfaceConstructor {
// <ctrl> + N. Only show this after two/three UPs or DOWNs, not on the first
// one.
[kHistoryNext]() {
if (this.historyIndex >= 0) {
this[kBeforeEdit](this.line, this.cursor);
const search = this[kSubstringSearch] || '';
let index = this.historyIndex - 1;
while (
index >= 0 &&
(!StringPrototypeStartsWith(this.history[index], search) ||
this.line === this.history[index])
) {
index--;
}
if (index === -1) {
this[kSetLine](search);
} else {
this[kSetLine](this[kNormalizeHistoryLineEndings](this.history[index], '\r', '\n'));
}
this.historyIndex = index;
this.cursor = this.line.length; // Set cursor to end of line.
this[kRefreshLine]();
}
if (!this.historyManager.canNavigateToNext()) { return; }
this[kBeforeEdit](this.line, this.cursor);
this[kSetLine](this.historyManager.navigateToNext(this[kSubstringSearch]));
this.cursor = this.line.length; // Set cursor to end of line.
this[kRefreshLine]();
}
[kMoveUpOrHistoryPrev]() {
@ -1218,26 +1154,12 @@ class Interface extends InterfaceConstructor {
}
[kHistoryPrev]() {
if (this.historyIndex < this.history.length && this.history.length) {
this[kBeforeEdit](this.line, this.cursor);
const search = this[kSubstringSearch] || '';
let index = this.historyIndex + 1;
while (
index < this.history.length &&
(!StringPrototypeStartsWith(this.history[index], search) ||
this.line === this.history[index])
) {
index++;
}
if (index === this.history.length) {
this[kSetLine](search);
} else {
this[kSetLine](this[kNormalizeHistoryLineEndings](this.history[index], '\r', '\n'));
}
this.historyIndex = index;
this.cursor = this.line.length; // Set cursor to end of line.
this[kRefreshLine]();
}
if (!this.historyManager.canNavigateToPrevious()) { return; }
this[kBeforeEdit](this.line, this.cursor);
this[kSetLine](this.historyManager.navigateToPrevious(this[kSubstringSearch]));
this.cursor = this.line.length; // Set cursor to end of line.
this[kRefreshLine]();
}
// Returns the last character's display position of the given string

View File

@ -40,14 +40,21 @@ function createRepl(env, opts, cb) {
opts.replMode = REPL.REPL_MODE_SLOPPY;
}
const historySize = Number(env.NODE_REPL_HISTORY_SIZE);
if (!NumberIsNaN(historySize) && historySize > 0) {
opts.historySize = historySize;
const size = Number(env.NODE_REPL_HISTORY_SIZE);
if (!NumberIsNaN(size) && size > 0) {
opts.size = size;
} else {
opts.historySize = 1000;
opts.size = 1000;
}
const repl = REPL.start(opts);
const term = 'terminal' in opts ? opts.terminal : process.stdout.isTTY;
repl.setupHistory(term ? env.NODE_REPL_HISTORY : '', cb);
opts.filePath = term ? env.NODE_REPL_HISTORY : '';
const repl = REPL.start(opts);
repl.setupHistory({
filePath: opts.filePath,
size: opts.size,
onHistoryFileLoaded: cb,
});
}

View File

@ -1,14 +1,21 @@
'use strict';
const {
ArrayPrototypeIndexOf,
ArrayPrototypeJoin,
ArrayPrototypePop,
ArrayPrototypeShift,
ArrayPrototypeSplice,
ArrayPrototypeUnshift,
Boolean,
FunctionPrototype,
RegExpPrototypeSymbolSplit,
StringPrototypeStartsWith,
StringPrototypeTrim,
Symbol,
} = primordials;
const { Interface } = require('readline');
const { validateNumber, validateArray } = require('internal/validators');
const path = require('path');
const fs = require('fs');
const os = require('os');
@ -17,168 +24,400 @@ let debug = require('internal/util/debuglog').debuglog('repl', (fn) => {
});
const permission = require('internal/process/permission');
const { clearTimeout, setTimeout } = require('timers');
const {
reverseString,
} = require('internal/readline/utils');
const noop = FunctionPrototype;
// XXX(chrisdickinson): The 15ms debounce value is somewhat arbitrary.
// The debounce is to guard against code pasted into the REPL.
const kDebounceHistoryMS = 15;
const kHistorySize = 30;
module.exports = setupHistory;
// Class fields
const kTimer = Symbol('_kTimer');
const kWriting = Symbol('_kWriting');
const kPending = Symbol('_kPending');
const kRemoveHistoryDuplicates = Symbol('_kRemoveHistoryDuplicates');
const kHistoryHandle = Symbol('_kHistoryHandle');
const kHistoryPath = Symbol('_kHistoryPath');
const kContext = Symbol('_kContext');
const kIsFlushing = Symbol('_kIsFlushing');
const kHistory = Symbol('_kHistory');
const kSize = Symbol('_kSize');
const kIndex = Symbol('_kIndex');
function _writeToOutput(repl, message) {
repl._writeToOutput(message);
repl._refreshLine();
}
// Class methods
const kNormalizeLineEndings = Symbol('_kNormalizeLineEndings');
const kWriteToOutput = Symbol('_kWriteToOutput');
const kOnLine = Symbol('_kOnLine');
const kOnExit = Symbol('_kOnExit');
const kInitializeHistory = Symbol('_kInitializeHistory');
const kHandleHistoryInitError = Symbol('_kHandleHistoryInitError');
const kHasWritePermission = Symbol('_kHasWritePermission');
const kValidateOptions = Symbol('_kValidateOptions');
const kResolveHistoryPath = Symbol('_kResolveHistoryPath');
const kReplHistoryMessage = Symbol('_kReplHistoryMessage');
const kFlushHistory = Symbol('_kFlushHistory');
const kGetHistoryPath = Symbol('_kGetHistoryPath');
function setupHistory(repl, historyPath, ready) {
// Empty string disables persistent history
if (typeof historyPath === 'string')
historyPath = StringPrototypeTrim(historyPath);
class ReplHistory {
constructor(context, options) {
this[kValidateOptions](options);
if (historyPath === '') {
repl._historyPrev = _replHistoryMessage;
return ready(null, repl);
this[kHistoryPath] = ReplHistory[kGetHistoryPath](options);
this[kContext] = context;
this[kTimer] = null;
this[kWriting] = false;
this[kPending] = false;
this[kRemoveHistoryDuplicates] = options.removeHistoryDuplicates || false;
this[kHistoryHandle] = null;
this[kIsFlushing] = false;
this[kSize] = options.size ?? context.historySize ?? kHistorySize;
this[kHistory] = options.history ?? [];
this[kIndex] = -1;
}
if (!historyPath) {
try {
historyPath = path.join(os.homedir(), '.node_repl_history');
} catch (err) {
_writeToOutput(repl, '\nError: Could not get the home directory.\n' +
'REPL session history will not be persisted.\n');
debug(err.stack);
repl._historyPrev = _replHistoryMessage;
return ready(null, repl);
}
}
if (permission.isEnabled() && permission.has('fs.write', historyPath) === false) {
_writeToOutput(repl, '\nAccess to FileSystemWrite is restricted.\n' +
'REPL session history will not be persisted.\n');
return ready(null, repl);
}
let timer = null;
let writing = false;
let pending = false;
repl.pause();
// History files are conventionally not readable by others:
// https://github.com/nodejs/node/issues/3392
// https://github.com/nodejs/node/pull/3394
fs.open(historyPath, 'a+', 0o0600, oninit);
function oninit(err, hnd) {
if (err) {
// Cannot open history file.
// Don't crash, just don't persist history.
_writeToOutput(repl, '\nError: Could not open history file.\n' +
'REPL session history will not be persisted.\n');
debug(err.stack);
repl._historyPrev = _replHistoryMessage;
repl.resume();
return ready(null, repl);
}
fs.close(hnd, onclose);
}
function onclose(err) {
if (err) {
return ready(err);
}
fs.readFile(historyPath, 'utf8', onread);
}
function onread(err, data) {
if (err) {
return ready(err);
initialize(onReadyCallback) {
// Empty string disables persistent history
if (this[kHistoryPath] === '') {
// Save a reference to the context's original _historyPrev
this.historyPrev = this[kContext]._historyPrev;
this[kContext]._historyPrev = this[kReplHistoryMessage].bind(this);
return onReadyCallback(null, this[kContext]);
}
if (data) {
repl.history = RegExpPrototypeSymbolSplit(/\r?\n+/, data, repl.historySize);
} else {
repl.history = [];
const resolvedPath = this[kResolveHistoryPath]();
if (!resolvedPath) {
ReplHistory[kWriteToOutput](
this[kContext],
'\nError: Could not get the home directory.\n' +
'REPL session history will not be persisted.\n',
);
// Save a reference to the context's original _historyPrev
this.historyPrev = this[kContext]._historyPrev;
this[kContext]._historyPrev = this[kReplHistoryMessage].bind(this);
return onReadyCallback(null, this[kContext]);
}
fs.open(historyPath, 'r+', onhandle);
}
function onhandle(err, hnd) {
if (err) {
return ready(err);
if (!this[kHasWritePermission]()) {
ReplHistory[kWriteToOutput](
this[kContext],
'\nAccess to FileSystemWrite is restricted.\n' +
'REPL session history will not be persisted.\n',
);
return onReadyCallback(null, this[kContext]);
}
fs.ftruncate(hnd, 0, (err) => {
repl._historyHandle = hnd;
repl.on('line', online);
repl.once('exit', onexit);
// Reading the file data out erases it
repl.once('flushHistory', function() {
if (!repl.closed) {
repl.resume();
ready(null, repl);
}
});
flushHistory();
this[kContext].pause();
this[kInitializeHistory](onReadyCallback).catch((err) => {
this[kHandleHistoryInitError](err, onReadyCallback);
});
}
// ------ history listeners ------
function online(line) {
repl._flushing = true;
addHistory(isMultiline, lastCommandErrored) {
const line = this[kContext].line;
if (timer) {
clearTimeout(timer);
if (line.length === 0) return '';
// If the history is disabled then return the line
if (this[kSize] === 0) return line;
// If the trimmed line is empty then return the line
if (StringPrototypeTrim(line).length === 0) return line;
// This is necessary because each line would be saved in the history while creating
// a new multiline, and we don't want that.
if (isMultiline && this[kIndex] === -1) {
ArrayPrototypeShift(this[kHistory]);
} else if (lastCommandErrored) {
// If the last command errored and we are trying to edit the history to fix it
// remove the broken one from the history
ArrayPrototypeShift(this[kHistory]);
}
timer = setTimeout(flushHistory, kDebounceHistoryMS);
}
const normalizedLine = ReplHistory[kNormalizeLineEndings](line, '\n', '\r');
function flushHistory() {
timer = null;
if (writing) {
pending = true;
return;
if (this[kHistory].length === 0 || this[kHistory][0] !== normalizedLine) {
if (this[kRemoveHistoryDuplicates]) {
// Remove older history line if identical to new one
const dupIndex = ArrayPrototypeIndexOf(this[kHistory], line);
if (dupIndex !== -1) ArrayPrototypeSplice(this[kHistory], dupIndex, 1);
}
// Add the new line to the history
ArrayPrototypeUnshift(this[kHistory], normalizedLine);
// Only store so many
if (this[kHistory].length > this[kSize])
ArrayPrototypePop(this[kHistory]);
}
writing = true;
const historyData = ArrayPrototypeJoin(repl.history, '\n');
fs.write(repl._historyHandle, historyData, 0, 'utf8', onwritten);
this[kIndex] = -1;
const finalLine = isMultiline ? reverseString(this[kHistory][0]) : this[kHistory][0];
// The listener could change the history object, possibly
// to remove the last added entry if it is sensitive and should
// not be persisted in the history, like a password
// Emit history event to notify listeners of update
this[kContext].emit('history', this[kHistory]);
return finalLine;
}
function onwritten(err, data) {
writing = false;
if (pending) {
pending = false;
online();
} else {
repl._flushing = Boolean(timer);
if (!repl._flushing) {
repl.emit('flushHistory');
canNavigateToNext() {
return this[kIndex] > -1 && this[kHistory].length > 0;
}
navigateToNext(substringSearch) {
if (!this.canNavigateToNext()) {
return null;
}
const search = substringSearch || '';
let index = this[kIndex] - 1;
while (
index >= 0 &&
(!StringPrototypeStartsWith(this[kHistory][index], search) ||
this[kContext].line === this[kHistory][index])
) {
index--;
}
this[kIndex] = index;
if (index === -1) {
return search;
}
return ReplHistory[kNormalizeLineEndings](this[kHistory][index], '\r', '\n');
}
canNavigateToPrevious() {
return this[kHistory].length !== this[kIndex] && this[kHistory].length > 0;
}
navigateToPrevious(substringSearch = '') {
if (!this.canNavigateToPrevious()) {
return null;
}
const search = substringSearch || '';
let index = this[kIndex] + 1;
while (
index < this[kHistory].length &&
(!StringPrototypeStartsWith(this[kHistory][index], search) ||
this[kContext].line === this[kHistory][index])
) {
index++;
}
this[kIndex] = index;
if (index === this[kHistory].length) {
return search;
}
return ReplHistory[kNormalizeLineEndings](this[kHistory][index], '\r', '\n');
}
get size() { return this[kSize]; }
get isFlushing() { return this[kIsFlushing]; }
get history() { return this[kHistory]; }
set history(value) { this[kHistory] = value; }
get index() { return this[kIndex]; }
set index(value) { this[kIndex] = value; }
// Start private methods
static [kGetHistoryPath](options) {
let historyPath = options.filePath;
if (typeof historyPath === 'string') {
historyPath = StringPrototypeTrim(historyPath);
}
return historyPath;
}
static [kNormalizeLineEndings](line, from, to) {
// Multiline history entries are saved reversed
// History is structured with the newest entries at the top
// and the oldest at the bottom. Multiline histories, however, only occupy
// one line in the history file. When loading multiline history with
// an old node binary, the history will be saved in the old format.
// This is why we need to reverse the multilines.
// Reversing the multilines is necessary when adding / editing and displaying them
return reverseString(line, from, to);
}
static [kWriteToOutput](context, message) {
if (typeof context._writeToOutput === 'function') {
context._writeToOutput(message);
if (typeof context._refreshLine === 'function') {
context._refreshLine();
}
}
}
function onexit() {
if (repl._flushing) {
repl.once('flushHistory', onexit);
[kResolveHistoryPath]() {
if (!this[kHistoryPath]) {
try {
this[kHistoryPath] = path.join(os.homedir(), '.node_repl_history');
return this[kHistoryPath];
} catch (err) {
debug(err.stack);
return null;
}
}
return this[kHistoryPath];
}
[kHasWritePermission]() {
return !(permission.isEnabled() &&
permission.has('fs.write', this[kHistoryPath]) === false);
}
[kValidateOptions](options) {
if (typeof options.history !== 'undefined') {
validateArray(options.history, 'history');
}
if (typeof options.size !== 'undefined') {
validateNumber(options.size, 'size', 0);
}
}
async [kInitializeHistory](onReadyCallback) {
try {
// Open and close file first to ensure it exists
// History files are conventionally not readable by others
// 0o0600 = read/write for owner only
const hnd = await fs.promises.open(this[kHistoryPath], 'a+', 0o0600);
await hnd.close();
let data;
try {
data = await fs.promises.readFile(this[kHistoryPath], 'utf8');
} catch (err) {
return this[kHandleHistoryInitError](err, onReadyCallback);
}
if (data) {
this[kHistory] = RegExpPrototypeSymbolSplit(/\r?\n+/, data, this[kSize]);
} else {
this[kHistory] = [];
}
validateArray(this[kHistory], 'history');
const handle = await fs.promises.open(this[kHistoryPath], 'r+');
this[kHistoryHandle] = handle;
await handle.truncate(0);
this[kContext].on('line', this[kOnLine].bind(this));
this[kContext].once('exit', this[kOnExit].bind(this));
this[kContext].once('flushHistory', () => {
if (!this[kContext].closed) {
this[kContext].resume();
onReadyCallback(null, this[kContext]);
}
});
await this[kFlushHistory]();
} catch (err) {
return this[kHandleHistoryInitError](err, onReadyCallback);
}
}
[kHandleHistoryInitError](err, onReadyCallback) {
// Cannot open history file.
// Don't crash, just don't persist history.
ReplHistory[kWriteToOutput](
this[kContext],
'\nError: Could not open history file.\n' +
'REPL session history will not be persisted.\n',
);
debug(err.stack);
// Save a reference to the context's original _historyPrev
this.historyPrev = this[kContext]._historyPrev;
this[kContext]._historyPrev = this[kReplHistoryMessage].bind(this);
this[kContext].resume();
return onReadyCallback(null, this[kContext]);
}
[kOnLine]() {
this[kIsFlushing] = true;
if (this[kTimer]) {
clearTimeout(this[kTimer]);
}
this[kTimer] = setTimeout(() => this[kFlushHistory](), kDebounceHistoryMS);
}
async [kFlushHistory]() {
this[kTimer] = null;
if (this[kWriting]) {
this[kPending] = true;
return;
}
repl.off('line', online);
fs.close(repl._historyHandle, noop);
this[kWriting] = true;
const historyData = ArrayPrototypeJoin(this[kHistory], '\n');
try {
await this[kHistoryHandle].write(historyData, 0, 'utf8');
this[kWriting] = false;
if (this[kPending]) {
this[kPending] = false;
this[kOnLine]();
} else {
this[kIsFlushing] = Boolean(this[kTimer]);
if (!this[kIsFlushing]) {
this[kContext].emit('flushHistory');
}
}
} catch (err) {
this[kWriting] = false;
debug('Error writing history file:', err);
}
}
async [kOnExit]() {
if (this[kIsFlushing]) {
this[kContext].once('flushHistory', this[kOnExit].bind(this));
return;
}
this[kContext].off('line', this[kOnLine].bind(this));
if (this[kHistoryHandle] !== null) {
try {
await this[kHistoryHandle].close();
} catch (err) {
debug('Error closing history file:', err);
}
}
}
[kReplHistoryMessage]() {
if (this[kHistory].length === 0) {
ReplHistory[kWriteToOutput](
this[kContext],
'\nPersistent history support disabled. ' +
'Set the NODE_REPL_HISTORY environment\nvariable to ' +
'a valid, user-writable path to enable.\n',
);
}
// First restore the original method on the context
this[kContext]._historyPrev = this.historyPrev;
// Then call it with the correct context
return this[kContext]._historyPrev();
}
}
function _replHistoryMessage() {
if (this.history.length === 0) {
_writeToOutput(
this,
'\nPersistent history support disabled. ' +
'Set the NODE_REPL_HISTORY environment\nvariable to ' +
'a valid, user-writable path to enable.\n',
);
}
this._historyPrev = Interface.prototype._historyPrev;
return this._historyPrev();
}
module.exports = {
ReplHistory,
};

View File

@ -186,7 +186,6 @@ const {
stopSigintWatchdog,
} = internalBinding('contextify');
const history = require('internal/repl/history');
const {
extensionFormatMap,
} = require('internal/modules/esm/formats');
@ -787,6 +786,8 @@ function REPLServer(prompt,
[text, self.editorMode ? self.completeOnEditorMode(cb) : cb]);
}
// All the parameters in the object are defining the "input" param of the
// InterfaceConstructor.
ReflectApply(Interface, this, [{
input: options.input,
output: options.output,
@ -1075,8 +1076,17 @@ function start(prompt, source, eval_, useGlobal, ignoreUndefined, replMode) {
prompt, source, eval_, useGlobal, ignoreUndefined, replMode);
}
REPLServer.prototype.setupHistory = function setupHistory(historyFile, cb) {
history(this, historyFile, cb);
REPLServer.prototype.setupHistory = function setupHistory(historyConfig = {}, cb) {
// TODO(puskin94): necessary because historyConfig can be a string for backwards compatibility
const options = typeof historyConfig === 'string' ?
{ filePath: historyConfig } :
historyConfig;
if (typeof cb === 'function') {
options.onHistoryFileLoaded = cb;
}
this.setupHistoryManager(options);
};
REPLServer.prototype.clearBufferedCommand = function clearBufferedCommand() {
@ -1084,7 +1094,7 @@ REPLServer.prototype.clearBufferedCommand = function clearBufferedCommand() {
};
REPLServer.prototype.close = function close() {
if (this.terminal && this._flushing && !this._closingOnFlush) {
if (this.terminal && this.historyManager.isFlushing && !this._closingOnFlush) {
this._closingOnFlush = true;
this.once('flushHistory', () =>
ReflectApply(Interface.prototype.close, this, []),

View File

@ -242,7 +242,7 @@ function runTest(assertCleaned) {
}
repl.once('close', () => {
if (repl._flushing) {
if (repl.historyManager.isFlushing) {
repl.once('flushHistory', onClose);
return;
}

View File

@ -0,0 +1,281 @@
'use strict';
const common = require('../common');
const fixtures = require('../common/fixtures');
const stream = require('stream');
const REPL = require('repl');
const assert = require('assert');
const fs = require('fs');
const os = require('os');
if (process.env.TERM === 'dumb') {
common.skip('skipping - dumb terminal');
}
const tmpdir = require('../common/tmpdir');
tmpdir.refresh();
// Mock os.homedir()
os.homedir = function() {
return tmpdir.path;
};
// Create an input stream specialized for testing an array of actions
class ActionStream extends stream.Stream {
run(data) {
const _iter = data[Symbol.iterator]();
const doAction = () => {
const next = _iter.next();
if (next.done) {
// Close the repl. Note that it must have a clean prompt to do so.
setImmediate(() => {
this.emit('keypress', '', { ctrl: true, name: 'd' });
});
return;
}
const action = next.value;
if (typeof action === 'object') {
this.emit('keypress', '', action);
} else {
this.emit('data', action);
}
setImmediate(doAction);
};
doAction();
}
resume() {}
pause() {}
}
ActionStream.prototype.readable = true;
// Mock keys
const UP = { name: 'up' };
const DOWN = { name: 'down' };
const ENTER = { name: 'enter' };
const CLEAR = { ctrl: true, name: 'u' };
// File paths
const historyFixturePath = fixtures.path('.node_repl_history');
const historyPath = tmpdir.resolve('.fixture_copy_repl_history');
const historyPathFail = fixtures.path('nonexistent_folder', 'filename');
const defaultHistoryPath = tmpdir.resolve('.node_repl_history');
const emptyHiddenHistoryPath = fixtures.path('.empty-hidden-repl-history-file');
const devNullHistoryPath = tmpdir.resolve('.dev-null-repl-history-file');
// Common message bits
const prompt = '> ';
const replDisabled = '\nPersistent history support disabled. Set the ' +
'NODE_REPL_HISTORY environment\nvariable to a valid, ' +
'user-writable path to enable.\n';
const homedirErr = '\nError: Could not get the home directory.\n' +
'REPL session history will not be persisted.\n';
const replFailedRead = '\nError: Could not open history file.\n' +
'REPL session history will not be persisted.\n';
const tests = [
// Makes sure that, if the history file is empty, the history is disabled
{
env: { NODE_REPL_HISTORY: '' },
test: [UP],
expected: [prompt, replDisabled, prompt]
},
// Makes sure that, if the history file is empty (when trimmed), the history is disabled
{
env: { NODE_REPL_HISTORY: ' ' },
test: [UP],
expected: [prompt, replDisabled, prompt]
},
// Properly loads the history file
{
env: { NODE_REPL_HISTORY: historyPath },
test: [UP, CLEAR],
expected: [prompt, `${prompt}'you look fabulous today'`, prompt]
},
// Properly navigates newly added history items
{
env: {},
test: [UP, '21', ENTER, "'42'", ENTER],
expected: [
prompt,
'2', '1', '21\n', prompt,
"'", '4', '2', "'", "'42'\n", prompt,
],
clean: false
},
{ // Requires the above test case, because navigating old history
env: {},
test: [UP, UP, UP, DOWN, ENTER],
expected: [
prompt,
`${prompt}'42'`,
`${prompt}21`,
prompt,
`${prompt}21`,
'21\n',
prompt,
]
},
// Making sure that only the configured number of history items are kept
{
env: { NODE_REPL_HISTORY: historyPath,
NODE_REPL_HISTORY_SIZE: 1 },
test: [UP, UP, DOWN, CLEAR],
expected: [
prompt,
`${prompt}'you look fabulous today'`,
prompt,
`${prompt}'you look fabulous today'`,
prompt,
]
},
// Making sure that the history file is not written to if it is not writable
{
env: { NODE_REPL_HISTORY: historyPathFail,
NODE_REPL_HISTORY_SIZE: 1 },
test: [UP],
expected: [prompt, replFailedRead, prompt, replDisabled, prompt]
},
// Checking the history file permissions
{
before: function before() {
if (common.isWindows) {
const execSync = require('child_process').execSync;
execSync(`ATTRIB +H "${emptyHiddenHistoryPath}"`, (err) => {
assert.ifError(err);
});
}
},
env: { NODE_REPL_HISTORY: emptyHiddenHistoryPath },
test: [UP],
expected: [prompt]
},
// Checking failures when os.homedir() fails
{
before: function before() {
// Mock os.homedir() failure
os.homedir = function() {
throw new Error('os.homedir() failure');
};
},
env: {},
test: [UP],
expected: [prompt, homedirErr, prompt, replDisabled, prompt]
},
// Checking that the history file can be set to /dev/null
{
before: function before() {
if (!common.isWindows)
fs.symlinkSync('/dev/null', devNullHistoryPath);
},
env: { NODE_REPL_HISTORY: devNullHistoryPath },
test: [UP],
expected: [prompt]
},
];
const numtests = tests.length;
function cleanupTmpFile() {
try {
// Write over the file, clearing any history
fs.writeFileSync(defaultHistoryPath, '');
} catch (err) {
if (err.code === 'ENOENT') return true;
throw err;
}
return true;
}
// Copy our fixture to the tmp directory
fs.createReadStream(historyFixturePath)
.pipe(fs.createWriteStream(historyPath)).on('unpipe', () => runTest());
const runTestWrap = common.mustCall(runTest, numtests);
function runTest(assertCleaned) {
const opts = tests.shift();
if (!opts) return; // All done
if (assertCleaned) {
try {
assert.strictEqual(fs.readFileSync(defaultHistoryPath, 'utf8'), '');
} catch (e) {
if (e.code !== 'ENOENT') {
console.error(`Failed test # ${numtests - tests.length}`);
throw e;
}
}
}
const test = opts.test;
const expected = opts.expected;
const clean = opts.clean;
const before = opts.before;
const size = opts.env.NODE_REPL_HISTORY_SIZE;
const filePath = opts.env.NODE_REPL_HISTORY;
if (before) before();
const repl = REPL.start({
input: new ActionStream(),
output: new stream.Writable({
write(chunk, _, next) {
const output = chunk.toString();
// Ignore escapes and blank lines
if (output.charCodeAt(0) === 27 || /^[\r\n]+$/.test(output))
return next();
try {
assert.strictEqual(output, expected.shift());
} catch (err) {
console.error(`Failed test # ${numtests - tests.length}`);
throw err;
}
next();
}
}),
prompt: prompt,
useColors: false,
terminal: true,
});
repl.setupHistory({
size,
filePath,
onHistoryFileLoaded,
removeHistoryDuplicates: false
});
function onHistoryFileLoaded(err, repl) {
if (err) {
console.error(`Failed test # ${numtests - tests.length}`);
throw err;
}
repl.once('close', () => {
if (repl.historyManager.isFlushing) {
repl.once('flushHistory', onClose);
return;
}
onClose();
});
function onClose() {
const cleaned = clean === false ? false : cleanupTmpFile();
try {
// Ensure everything that we expected was output
assert.strictEqual(expected.length, 0);
setImmediate(runTestWrap, cleaned);
} catch (err) {
console.error(`Failed test # ${numtests - tests.length}`);
throw err;
}
}
repl.inputStream.run(test);
}
}

View File

@ -204,7 +204,7 @@ function runTest(assertCleaned) {
const clean = opts.clean;
const before = opts.before;
const historySize = opts.env.NODE_REPL_HISTORY_SIZE;
const historyFile = opts.env.NODE_REPL_HISTORY;
const file = opts.env.NODE_REPL_HISTORY;
if (before) before();
@ -230,17 +230,17 @@ function runTest(assertCleaned) {
prompt: prompt,
useColors: false,
terminal: true,
historySize: historySize
historySize
});
repl.setupHistory(historyFile, function(err, repl) {
repl.setupHistory(file, function(err, repl) {
if (err) {
console.error(`Failed test # ${numtests - tests.length}`);
throw err;
}
repl.once('close', () => {
if (repl._flushing) {
if (repl.historyManager.isFlushing) {
repl.once('flushHistory', onClose);
return;
}