webprogbase-console-view
Version:
Console browser and server app module with simplified express-like API
363 lines (312 loc) • 11.7 kB
JavaScript
const { Socket } = require('./../core/net');
const { SourceError } = require('./../core/error');
const { Request, Response } = require('./../core/http');
const { InputForm } = require('./../core/data');
const initialUserState = '/';
const responseTimeoutMillis = 3000;
const goBackUserState = '<';
const goForwardUserState = '>';
const cancelInputForm = '<<';
const goBackFieldInputForm = '<';
class History {
constructor(browser) {
this.states = [];
this.index = -1;
this.browser = browser;
}
pushState(name, data) {
if (this.state && this.state.name === name) { return; } // ignore
// clear history tail
while (this.index !== this.states.length - 1) {
this.states.pop();
}
this.states.push({ name, data });
this.index += 1;
}
popState() {
if (this.length <= 0) {
throw new SourceError(this, `Invalid usage. No states to pop`);
}
this.states.pop();
this.index -= 1;
}
back() {
if (this.index <= 0) {
throw new SourceError(this, `Invalid usage. No previous state`);
}
this.index -= 1;
this.browser.sendRequest(new Request(this.state.name, this.state.data));
}
forward() {
if (this.index >= this.states.length) {
throw new SourceError(this, `Invalid usage. No next state`);
}
this.index += 1;
this.browser.sendRequest(new Request(this.state.name, this.state.data));
}
get length() {
return this.states.length;
}
get state() {
return this.states[this.index];
}
get prev() {
return this.index > 0;
}
get next() {
return this.index < this.states.length - 1;
}
}
const BrowserViewState = Object.freeze({
Error: {},
ShowText: {},
InputForm: {}
});
class ConsoleBrowser {
constructor() {
this.viewState = BrowserViewState.ShowText; // current view state (text | form)
this.history = new History(this); // state changes history & navigation
// state data
this.states = []; // current states available
this.form = null; // current state form
this.fieldIndex = 0; // current state form current input field index
this.timeout = null; // request timeout
const stdin = process.openStdin();
// subscribe for console user input (fires after Enter is pressed)
stdin.addListener("data", this._onInput.bind(this));
}
open(serverPort) {
this.serverPort = serverPort;
this.sendRequest(new Request(initialUserState));
}
sendRequest(req) {
const stateName = req.state;
const formData = req.data;
this.history.pushState(stateName, formData);
this._clearScreen();
this._showStateName();
send.bind(this)(req, this._handleResponse.bind(this));
function send(req, resHandler) {
const serverSocket = new Socket();
if (!serverSocket.connect(this.serverPort)) {
const errorMsg = `The app can't be reached.\nApp ${this.serverPort} refused to connect.`;
this._toError(new SourceError(this, errorMsg));
return;
}
serverSocket.on('message', message => {
if (message instanceof Response) {
clearTimeout(this.timeout);
resHandler(message);
}
serverSocket.close();
});
this.timeout = setTimeout(onTimeout.bind(this), responseTimeoutMillis);
serverSocket.send(req);
function onTimeout() {
const errorMsg = `No data received.\nUnable to load the state because the server sent no data.`;
this._toError(new SourceError(this, errorMsg));
}
}
}
_handleResponse(res) {
if (!res.isHandled) {
const errorMsg = `Not found.\nThe requested state was not found on server.`;
this._toError(new SourceError(this, errorMsg));
return;
}
if (res.redirectState) {
this.history.popState();
this._validateStateData(res.data, res.redirectState);
this._redirect(res.redirectState, res.data);
} else {
if (res.data instanceof InputForm) {
this._toInputForm(res.text, res.data);
} else if (typeof(res.data) === 'object') {
this._toShowText(res.text, res.data);
}
}
}
_onInput(dataObject) {
const inputString = dataObject.toString().trim();
if (this.viewState === BrowserViewState.ShowText) {
if (!inputString) { return; } // ignore empty input
const inputState = this.states[inputString];
if (!inputState) {
console.log(`Invalid state: '${inputString}'. Try again.`);
this._showNextStateInput(false);
return;
}
if (inputString === goBackUserState) {
this.history.back();
} else if (inputString === goForwardUserState) {
this.history.forward();
} else {
this.sendRequest(new Request(inputString, inputState.data));
}
} else if (this.viewState === BrowserViewState.InputForm) {
if (inputString === cancelInputForm) {
this.history.back(); // cancel form input
} else if (inputString === goBackFieldInputForm) {
if (this.fieldIndex === 0) {
this.history.back(); // cancel form input
} else {
const fieldData = this.form.getFieldAt(this.fieldIndex)[1];
fieldData.value = null; // clear current value
delete fieldData.auto; // disable auto
this.fieldIndex -= 1;
this._showFormFieldInput(); // prev field
}
} else {
const fieldData = this.form.getFieldAt(this.fieldIndex)[1];
const value = (inputString.length === 0 && fieldData.default)
? fieldData.default
: inputString;
this.form.setValueAt(this.fieldIndex, value);
this.fieldIndex += 1;
this._checkFormFieldInput();
}
}
}
_toError(err) {
this._toShowText(err.toString());
}
_toShowText(text, states) {
this.viewState = BrowserViewState.ShowText;
this.states = prepareStates.bind(this)(states);
//
this._showText(text);
this._showNextStateInput();
function prepareStates(states) {
const res = states || {};
res[initialUserState] = "Home";
if (this.history.prev) {
res[goBackUserState] = "Back";
}
if (this.history.next) {
res[goForwardUserState] = "Forward";
}
return this._expandStates(res);
}
}
_toInputForm(text, form) {
if (form.length === 0) {
throw new SourceError(this, `Form has no fields`);
}
//
this.viewState = BrowserViewState.InputForm;
this.form = form;
this.fieldIndex = 0;
//
this._showText(text);
this._showFormInput();
this._checkFormFieldInput();
}
_checkFormFieldInput() {
while (this.fieldIndex < this.form.length) {
const fieldData = this.form.getFieldAt(this.fieldIndex)[1];
if (fieldData.auto) {
fieldData.value = fieldData.auto;
this._showFormFieldInput(fieldData.value);
this.fieldIndex += 1;
} else {
break;
}
}
if (this.fieldIndex < this.form.length) {
this._showFormFieldInput(); // next field
} else {
// form data is ready
this.sendRequest(new Request(this.form.state, this.form.getFormData()));
}
}
_redirect(redirectState, stateData = null) {
this.sendRequest(new Request(redirectState, stateData));
}
_clearScreen() {
if (process.platform === "win32") {
process.stdout.write('\x1Bc'); // clear console (Win)
}
console.clear();
}
_showStateName() {
const state = this.history.state;
const dataString = state.data
? ` ${JSON.stringify(state.data)}`
: ``;
console.log(`[${this.serverPort}] ${state.name}${dataString}`);
console.log('-------------------------------');
}
_showHistory() {
console.log(this.history.states.map(x => x.name).join(", "));
console.log('-------------------------------');
}
_showText(text) {
console.log(text);
console.log('-------------------------------');
}
_showNextStateInput(showOptions = true) {
if (showOptions) {
console.log('Choose the next state:');
for (const [key, stateData] of Object.entries(this.states)) {
const dataString = stateData.data
? ` ${JSON.stringify(stateData.data)}`
: ``;
console.log(`(${key}${dataString}) ${stateData.description}`);
}
}
process.stdout.write(`: `);
}
_showFormInput() {
console.log(
`Input form data:\n` +
`(${cancelInputForm}) Cancel form input\n` +
`(${goBackFieldInputForm}) Go back to previous field\n`);
}
_showFormFieldInput(autoValue = null) {
const field = this.form.getFieldAt(this.fieldIndex);
const key = field[0];
const fieldData = field[1];
const description = fieldData.description;
const defaultValueStr = fieldData.default ? ` (${fieldData.default})` : ``;
const autoValueStr = autoValue ? `${autoValue} (auto)\n` : ``;
const viewIndex = this.fieldIndex + 1;
process.stdout.write(`[${viewIndex}/${this.form.length}] (${key}) ${description}:${defaultValueStr} ${autoValueStr}`);
}
_expandStates(data) {
if (data === null) { return null; }
if (typeof data !== "object") {
throw new SourceError("Providing non-object as states dictionary or form");
}
// state links
const expandedStatesDict = {};
for (const [key, value] of Object.entries(data)) {
const stateData = getStateData(value);
this._validateStateData(stateData.data, key);
expandedStatesDict[key] = stateData;
}
return expandedStatesDict;
function getStateData(value) {
if (typeof value === 'object') {
return value;
} else {
return {
description: value,
data: null,
};
}
}
}
_validateStateData(data, stateName) {
if (data === null) { return; }
if (typeof data !== "object") {
throw new SourceError(`Providing non-object as state data for "${stateName}"`);
}
try {
// simplified check
void JSON.stringify(data);
} catch (e) {
throw new SourceError(`Providing circular structure as state data for "${stateName}"`);
}
}
}
module.exports = { ConsoleBrowser };