CR 21114 -- Implement modules/require support into hifi
Showing the changes to libraries/script-engine/src/ScriptEngine.cpp (1 of 29 files). Show all
Unified · Split
| @@ -19,6 +19,9 @@ · 18 unmodified lines | ||
| 19 | 19 | #include <QtCore/QThread> |
| 20 | 20 | #include <QtCore/QRegularExpression> |
| 21 | 21 | |
| 22 | +#include <QtCore/QFuture> | |
| 23 | +#include <QtConcurrent/QtConcurrentRun> | |
| 24 | + | |
| 22 | 25 | #include <QtWidgets/QMainWindow> |
| 23 | 26 | #include <QtWidgets/QApplication> |
| 24 | 27 | |
| @@ -72,13 +75,18 @@ · 47 unmodified lines | ||
| 72 | 75 | |
| 73 | 76 | #include "MIDIEvent.h" |
| 74 | 77 | |
| 78 | +const QString ScriptEngine::_SETTINGS_ENABLE_EXTENDED_EXCEPTIONS { | |
| 79 | + "com.highfidelity.experimental.enableExtendedJSExceptions" | |
| 80 | +}; | |
| 81 | + | |
| 82 | +static const int MAX_MODULE_ID_LENGTH { 4096 }; | |
| 83 | +static const int MAX_DEBUG_VALUE_LENGTH { 80 }; | |
| 84 | + | |
| 75 | 85 | static const QScriptEngine::QObjectWrapOptions DEFAULT_QOBJECT_WRAP_OPTIONS = |
| 76 | 86 | QScriptEngine::ExcludeDeleteLater | QScriptEngine::ExcludeChildObjects; |
| 77 | 87 | static const QScriptValue::PropertyFlags READONLY_PROP_FLAGS { QScriptValue::ReadOnly | QScriptValue::Undeletable }; |
| 78 | 88 | static const QScriptValue::PropertyFlags READONLY_HIDDEN_PROP_FLAGS { READONLY_PROP_FLAGS | QScriptValue::SkipInEnumeration }; |
| 79 | 89 | |
| 80 | − | |
| 81 | − | |
| 82 | 90 | static const bool HIFI_AUTOREFRESH_FILE_SCRIPTS { true }; |
| 83 | 91 | |
| 84 | 92 | Q_DECLARE_METATYPE(QScriptEngine::FunctionSignature) |
| @@ -86,7 +94,7 @@ int functionSignatureMetaID = qRegisterMetaType<QScriptEngine::FunctionSignature · 1 unmodified line | ||
| 86 | 94 | |
| 87 | 95 | Q_LOGGING_CATEGORY(scriptengineScript, "hifi.scriptengine.script") |
| 88 | 96 | |
| 89 | −static QScriptValue debugPrint(QScriptContext* context, QScriptEngine* engine){ | |
| 97 | +static QScriptValue debugPrint(QScriptContext* context, QScriptEngine* engine) { | |
| 90 | 98 | QString message = ""; |
| 91 | 99 | for (int i = 0; i < context->argumentCount(); i++) { |
| 92 | 100 | if (i > 0) { |
| @@ -143,7 +151,7 @@ QString encodeEntityIdIntoEntityUrl(const QString& url, const QString& entityID) · 50 unmodified lines | ||
| 143 | 151 | } |
| 144 | 152 | |
| 145 | 153 | QString ScriptEngine::logException(const QScriptValue& exception) { |
| 146 | − auto message = formatException(exception); | |
| 154 | + auto message = formatException(exception, _enableExtendedJSExceptions.get()); | |
| 147 | 155 | scriptErrorMessage(message); |
| 148 | 156 | return message; |
| 149 | 157 | } |
| @@ -335,7 +343,7 @@ void ScriptEngine::runInThread() { · 185 unmodified lines | ||
| 335 | 343 | // The thread interface cannot live on itself, and we want to move this into the thread, so |
| 336 | 344 | // the thread cannot have this as a parent. |
| 337 | 345 | QThread* workerThread = new QThread(); |
| 338 | − workerThread->setObjectName(QString("Script Thread:") + getFilename()); | |
| 346 | + workerThread->setObjectName(QString("js:") + getFilename().replace("about:","")); | |
| 339 | 347 | moveToThread(workerThread); |
| 340 | 348 | |
| 341 | 349 | // NOTE: If you connect any essential signals for proper shutdown or cleanup of |
| @@ -534,6 +542,40 @@ static QScriptValue createScriptableResourcePrototype(QScriptEngine* engine) { · 192 unmodified lines | ||
| 534 | 542 | return prototype; |
| 535 | 543 | } |
| 536 | 544 | |
| 545 | +void ScriptEngine::resetModuleCache(bool deleteScriptCache) { | |
| 546 | + if (QThread::currentThread() != thread()) { | |
| 547 | + executeOnScriptThread([=]() { resetModuleCache(deleteScriptCache); }); | |
| 548 | + return; | |
| 549 | + } | |
| 550 | + auto jsRequire = globalObject().property("Script").property("require"); | |
| 551 | + auto cache = jsRequire.property("cache"); | |
| 552 | + auto cacheMeta = jsRequire.data(); | |
| 553 | + | |
| 554 | + if (deleteScriptCache) { | |
| 555 | + QScriptValueIterator it(cache); | |
| 556 | + while (it.hasNext()) { | |
| 557 | + it.next(); | |
| 558 | + if (it.flags() & QScriptValue::SkipInEnumeration) { | |
| 559 | + continue; | |
| 560 | + } | |
| 561 | + qCDebug(scriptengine) << "resetModuleCache(true) -- staging " << it.name() << " for cache reset at next require"; | |
| 562 | + cacheMeta.setProperty(it.name(), true); | |
| 563 | + } | |
| 564 | + } | |
| 565 | + cache = newObject(); | |
| 566 | + if (!cacheMeta.isObject()) { | |
| 567 | + cacheMeta = newObject(); | |
| 568 | + cacheMeta.setProperty("id", "Script.require.cacheMeta"); | |
| 569 | + cacheMeta.setProperty("type", "cacheMeta"); | |
| 570 | + jsRequire.setData(cacheMeta); | |
| 571 | + } | |
| 572 | + cache.setProperty("__created__", (double)QDateTime::currentMSecsSinceEpoch(), QScriptValue::SkipInEnumeration); | |
| 573 | +#if DEBUG_JS_MODULES | |
| 574 | + cache.setProperty("__meta__", cacheMeta, READONLY_HIDDEN_PROP_FLAGS); | |
| 575 | +#endif | |
| 576 | + jsRequire.setProperty("cache", cache, READONLY_PROP_FLAGS); | |
| 577 | +} | |
| 578 | + | |
| 537 | 579 | void ScriptEngine::init() { |
| 538 | 580 | if (_isInitialized) { |
| 539 | 581 | return; // only initialize once |
| @@ -587,6 +629,15 @@ void ScriptEngine::init() { · 47 unmodified lines | ||
| 587 | 629 | |
| 588 | 630 | registerGlobalObject("Script", this); |
| 589 | 631 | |
| 632 | + { | |
| 633 | + // set up Script.require.resolve and Script.require.cache | |
| 634 | + auto Script = globalObject().property("Script"); | |
| 635 | + auto require = Script.property("require"); | |
| 636 | + auto resolve = Script.property("_requireResolve"); | |
| 637 | + require.setProperty("resolve", resolve, READONLY_PROP_FLAGS); | |
| 638 | + resetModuleCache(); | |
| 639 | + } | |
| 640 | + | |
| 590 | 641 | registerGlobalObject("Audio", &AudioScriptingInterface::getInstance()); |
| 591 | 642 | registerGlobalObject("Entities", entityScriptingInterface.data()); |
| 592 | 643 | registerGlobalObject("Quat", &_quatLibrary); |
| @@ -859,6 +910,11 @@ void ScriptEngine::addEventHandler(const EntityItemID& entityID, const QString& · 266 unmodified lines | ||
| 859 | 910 | handlersForEvent << handlerData; // Note that the same handler can be added many times. See removeEntityEventHandler(). |
| 860 | 911 | } |
| 861 | 912 | |
| 913 | +// this is not redundant -- the version in BaseScriptEngine is specifically not Q_INVOKABLE | |
| 914 | +QScriptValue ScriptEngine::evaluateInClosure(const QScriptValue& closure, const QScriptProgram& program) { | |
| 915 | + return BaseScriptEngine::evaluateInClosure(closure, program); | |
| 916 | +} | |
| 917 | + | |
| 862 | 918 | QScriptValue ScriptEngine::evaluate(const QString& sourceCode, const QString& fileName, int lineNumber) { |
| 863 | 919 | if (DependencyManager::get<ScriptEngines>()->isStopped()) { |
| 864 | 920 | return QScriptValue(); // bail early |
| @@ -881,29 +937,26 @@ QScriptValue ScriptEngine::evaluate(const QString& sourceCode, const QString& fi · 16 unmodified lines | ||
| 881 | 937 | // Check syntax |
| 882 | 938 | auto syntaxError = lintScript(sourceCode, fileName); |
| 883 | 939 | if (syntaxError.isError()) { |
| 884 | − if (isEvaluating()) { | |
| 885 | − currentContext()->throwValue(syntaxError); | |
| 886 | − } else { | |
| 940 | + if (!isEvaluating()) { | |
| 887 | 941 | syntaxError.setProperty("detail", "evaluate"); |
| 888 | − emit unhandledException(syntaxError); | |
| 889 | 942 | } |
| 943 | + raiseException(syntaxError); | |
| 944 | + maybeEmitUncaughtException("lint"); | |
| 890 | 945 | return syntaxError; |
| 891 | 946 | } |
| 892 | 947 | QScriptProgram program { sourceCode, fileName, lineNumber }; |
| 893 | 948 | if (program.isNull()) { |
| 894 | 949 | // can this happen? |
| 895 | 950 | auto err = makeError("could not create QScriptProgram for " + fileName); |
| 896 | − emit unhandledException(err); | |
| 951 | + raiseException(err); | |
| 952 | + maybeEmitUncaughtException("compile"); | |
| 897 | 953 | return err; |
| 898 | 954 | } |
| 899 | 955 | |
| 900 | 956 | QScriptValue result; |
| 901 | 957 | { |
| 902 | 958 | result = BaseScriptEngine::evaluate(program); |
| 903 | − if (!isEvaluating() && hasUncaughtException()) { | |
| 904 | − emit unhandledException(cloneUncaughtException(__FUNCTION__)); | |
| 905 | − clearExceptions(); | |
| 906 | − } | |
| 959 | + maybeEmitUncaughtException("evaluate"); | |
| 907 | 960 | } |
| 908 | 961 | return result; |
| 909 | 962 | } |
| @@ -926,10 +979,7 @@ void ScriptEngine::run() { · 16 unmodified lines | ||
| 926 | 979 | |
| 927 | 980 | { |
| 928 | 981 | evaluate(_scriptContents, _fileNameString); |
| 929 | − if (!isEvaluating() && hasUncaughtException()) { | |
| 930 | − emit unhandledException(cloneUncaughtException(__FUNCTION__)); | |
| 931 | − clearExceptions(); | |
| 932 | − } | |
| 982 | + maybeEmitUncaughtException(__FUNCTION__); | |
| 933 | 983 | } |
| 934 | 984 | #ifdef _WIN32 |
| 935 | 985 | // VS13 does not sleep_until unless it uses the system_clock, see: |
| @@ -1300,11 +1350,361 @@ void ScriptEngine::print(const QString& message) { · 364 unmodified lines | ||
| 1300 | 1350 | emit printedMessage(message); |
| 1301 | 1351 | } |
| 1302 | 1352 | |
| 1353 | +// Script.require.resolve -- like resolvePath, but performs more validation and throws exceptions on invalid module identifiers (for consistency with Node.js) | |
| 1354 | +QString ScriptEngine::_requireResolve(const QString& moduleId, const QString& relativeTo) { | |
| 1355 | + if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) { | |
| 1356 | + return QString(); | |
| 1357 | + } | |
| 1358 | + QUrl defaultScriptsLoc = defaultScriptsLocation(); | |
| 1359 | + QUrl url(moduleId); | |
| 1360 | + | |
| 1361 | + auto displayId = moduleId; | |
| 1362 | + if (displayId.length() > MAX_DEBUG_VALUE_LENGTH) { | |
| 1363 | + displayId = displayId.mid(0, MAX_DEBUG_VALUE_LENGTH) + "..."; | |
| 1364 | + } | |
| 1365 | + auto message = QString("Cannot find module '%1' (%2)").arg(displayId); | |
| 1366 | + | |
| 1367 | + auto throwResolveError = [&](const QScriptValue& error) -> QString { | |
| 1368 | + raiseException(error); | |
| 1369 | + maybeEmitUncaughtException("require.resolve"); | |
| 1370 | + return QString(); | |
| 1371 | + }; | |
| 1372 | + | |
| 1373 | + // de-fuzz the input a little by restricting to rational sizes | |
| 1374 | + auto idLength = url.toString().length(); | |
| 1375 | + if (idLength < 1 || idLength > MAX_MODULE_ID_LENGTH) { | |
| 1376 | + auto details = QString("rejecting invalid module id size (%1 chars [1,%2])") | |
| 1377 | + .arg(idLength).arg(MAX_MODULE_ID_LENGTH); | |
| 1378 | + return throwResolveError(makeError(message.arg(details), "RangeError")); | |
| 1379 | + } | |
| 1380 | + | |
| 1381 | + // this regex matches: absolute, dotted or path-like URLs | |
| 1382 | + // (ie: the kind of stuff ScriptEngine::resolvePath already handles) | |
| 1383 | + QRegularExpression qualified ("^\\w+:|^/|^[.]{1,2}(/|$)"); | |
| 1384 | + | |
| 1385 | + // this is for module.require (which is a bound version of require that's always relative to the module path) | |
| 1386 | + if (!relativeTo.isEmpty()) { | |
| 1387 | + url = QUrl(relativeTo).resolved(moduleId); | |
| 1388 | + url = resolvePath(url.toString()); | |
| 1389 | + } else if (qualified.match(moduleId).hasMatch()) { | |
| 1390 | + url = resolvePath(moduleId); | |
| 1391 | + } else { | |
| 1392 | + // check if the moduleId refers to a "system" module | |
| 1393 | + QString systemPath = defaultScriptsLoc.path(); | |
| 1394 | + QString systemModulePath = QString("%1/modules/%2.js").arg(systemPath).arg(moduleId); | |
| 1395 | + url = defaultScriptsLoc; | |
| 1396 | + url.setPath(systemModulePath); | |
| 1397 | + if (!QFileInfo(url.toLocalFile()).isFile()) { | |
| 1398 | + if (!moduleId.contains("./")) { | |
| 1399 | + // the user might be trying to refer to a relative file without anchoring it | |
| 1400 | + // let's do them a favor and test for that case -- offering specific advice if detected | |
| 1401 | + auto unanchoredUrl = resolvePath("./" + moduleId); | |
| 1402 | + if (QFileInfo(unanchoredUrl.toLocalFile()).isFile()) { | |
| 1403 | + auto msg = QString("relative module ids must be anchored; use './%1' instead") | |
| 1404 | + .arg(moduleId); | |
| 1405 | + return throwResolveError(makeError(message.arg(msg))); | |
| 1406 | + } | |
| 1407 | + } | |
| 1408 | + return throwResolveError(makeError(message.arg("system module not found"))); | |
| 1409 | + } | |
| 1410 | + } | |
| 1411 | + | |
| 1412 | + if (url.isRelative()) { | |
| 1413 | + return throwResolveError(makeError(message.arg("could not resolve module id"))); | |
| 1414 | + } | |
| 1415 | + | |
| 1416 | + // if it looks like a local file, verify that it's an allowed path and really a file | |
| 1417 | + if (url.isLocalFile()) { | |
| 1418 | + QFileInfo file(url.toLocalFile()); | |
| 1419 | + QUrl canonical = url; | |
| 1420 | + if (file.exists()) { | |
| 1421 | + canonical.setPath(file.canonicalFilePath()); | |
| 1422 | + } | |
| 1423 | + | |
| 1424 | + bool disallowOutsideFiles = !defaultScriptsLocation().isParentOf(canonical) && !currentSandboxURL.isLocalFile(); | |
| 1425 | + if (disallowOutsideFiles && !PathUtils::isDescendantOf(canonical, currentSandboxURL)) { | |
| 1426 | + return throwResolveError(makeError(message.arg( | |
| 1427 | + QString("path '%1' outside of origin script '%2' '%3'") | |
| 1428 | + .arg(PathUtils::stripFilename(url)) | |
| 1429 | + .arg(PathUtils::stripFilename(currentSandboxURL)) | |
| 1430 | + .arg(canonical.toString()) | |
| 1431 | + ))); | |
| 1432 | + } | |
| 1433 | + if (!file.exists()) { | |
| 1434 | + return throwResolveError(makeError(message.arg("path does not exist: " + url.toLocalFile()))); | |
| 1435 | + } | |
| 1436 | + if (!file.isFile()) { | |
| 1437 | + return throwResolveError(makeError(message.arg("path is not a file: " + url.toLocalFile()))); | |
| 1438 | + } | |
| 1439 | + } | |
| 1440 | + | |
| 1441 | + maybeEmitUncaughtException(__FUNCTION__); | |
| 1442 | + return url.toString(); | |
| 1443 | +} | |
| 1444 | + | |
| 1445 | +// retrieves the current parent module from the JS scope chain | |
| 1446 | +QScriptValue ScriptEngine::currentModule() { | |
| 1447 | + if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) { | |
| 1448 | + return unboundNullValue(); | |
| 1449 | + } | |
| 1450 | + auto jsRequire = globalObject().property("Script").property("require"); | |
| 1451 | + auto cache = jsRequire.property("cache"); | |
| 1452 | + auto candidate = QScriptValue(); | |
| 1453 | + for (auto c = currentContext(); c && !candidate.isObject(); c = c->parentContext()) { | |
| 1454 | + QScriptContextInfo contextInfo { c }; | |
| 1455 | + candidate = cache.property(contextInfo.fileName()); | |
| 1456 | + } | |
| 1457 | + if (!candidate.isObject()) { | |
| 1458 | + return QScriptValue(); | |
| 1459 | + } | |
| 1460 | + return candidate; | |
| 1461 | +} | |
| 1462 | + | |
| 1463 | +// replaces or adds "module" to "parent.children[]" array | |
| 1464 | +// (for consistency with Node.js and userscript cache invalidation without "cache busters") | |
| 1465 | +bool ScriptEngine::registerModuleWithParent(const QScriptValue& module, const QScriptValue& parent) { | |
| 1466 | + auto children = parent.property("children"); | |
| 1467 | + if (children.isArray()) { | |
| 1468 | + auto key = module.property("id"); | |
| 1469 | + auto length = children.property("length").toInt32(); | |
| 1470 | + for (int i = 0; i < length; i++) { | |
| 1471 | + if (children.property(i).property("id").strictlyEquals(key)) { | |
| 1472 | + qCDebug(scriptengine_module) << key.toString() << " updating parent.children[" << i << "] = module"; | |
| 1473 | + children.setProperty(i, module); | |
| 1474 | + return true; | |
| 1475 | + } | |
| 1476 | + } | |
| 1477 | + qCDebug(scriptengine_module) << key.toString() << " appending parent.children[" << length << "] = module"; | |
| 1478 | + children.setProperty(length, module); | |
| 1479 | + return true; | |
| 1480 | + } else if (parent.isValid()) { | |
| 1481 | + qCDebug(scriptengine_module) << "registerModuleWithParent -- unrecognized parent" << parent.toVariant().toString(); | |
| 1482 | + } | |
| 1483 | + return false; | |
| 1484 | +} | |
| 1485 | + | |
| 1486 | +// creates a new JS "module" Object with default metadata properties | |
| 1487 | +QScriptValue ScriptEngine::newModule(const QString& modulePath, const QScriptValue& parent) { | |
| 1488 | + auto closure = newObject(); | |
| 1489 | + auto exports = newObject(); | |
| 1490 | + auto module = newObject(); | |
| 1491 | + qCDebug(scriptengine_module) << "newModule" << modulePath << parent.property("filename").toString(); | |
| 1492 | + | |
| 1493 | + closure.setProperty("module", module, READONLY_PROP_FLAGS); | |
| 1494 | + | |
| 1495 | + // note: this becomes the "exports" free variable, so should not be set read only | |
| 1496 | + closure.setProperty("exports", exports); | |
| 1497 | + | |
| 1498 | + // make the closure available to module instantiation | |
| 1499 | + module.setProperty("__closure__", closure, READONLY_HIDDEN_PROP_FLAGS); | |
| 1500 | + | |
| 1501 | + // for consistency with Node.js Module | |
| 1502 | + module.setProperty("id", modulePath, READONLY_PROP_FLAGS); | |
| 1503 | + module.setProperty("filename", modulePath, READONLY_PROP_FLAGS); | |
| 1504 | + module.setProperty("exports", exports); // not readonly | |
| 1505 | + module.setProperty("loaded", false, READONLY_PROP_FLAGS); | |
| 1506 | + module.setProperty("parent", parent, READONLY_PROP_FLAGS); | |
| 1507 | + module.setProperty("children", newArray(), READONLY_PROP_FLAGS); | |
| 1508 | + | |
| 1509 | + // module.require is a bound version of require that always resolves relative to that module's path | |
| 1510 | + auto boundRequire = QScriptEngine::evaluate("(function(id) { return Script.require(Script.require.resolve(id, this.filename)); })", "(boundRequire)"); | |
| 1511 | + module.setProperty("require", boundRequire, READONLY_PROP_FLAGS); | |
| 1512 | + | |
| 1513 | + return module; | |
| 1514 | +} | |
| 1515 | + | |
| 1516 | +// synchronously fetch a module's source code using BatchLoader | |
| 1517 | +QVariantMap ScriptEngine::fetchModuleSource(const QString& modulePath, const bool forceDownload) { | |
| 1518 | + using UrlMap = QMap<QUrl, QString>; | |
| 1519 | + auto scriptCache = DependencyManager::get<ScriptCache>(); | |
| 1520 | + QVariantMap req; | |
| 1521 | + qCDebug(scriptengine_module) << "require.fetchModuleSource: " << QUrl(modulePath).fileName() << QThread::currentThread(); | |
| 1522 | + | |
| 1523 | + auto onload = [=, &req](const UrlMap& data, const UrlMap& _status) { | |
| 1524 | + auto url = modulePath; | |
| 1525 | + auto status = _status[url]; | |
| 1526 | + auto contents = data[url]; | |
| 1527 | + qCDebug(scriptengine_module) << "require.fetchModuleSource.onload: " << QUrl(url).fileName() << status << QThread::currentThread(); | |
| 1528 | + if (isStopping()) { | |
| 1529 | + req["status"] = "Stopped"; | |
| 1530 | + req["success"] = false; | |
| 1531 | + } else { | |
| 1532 | + req["url"] = url; | |
| 1533 | + req["status"] = status; | |
| 1534 | + req["success"] = ScriptCache::isSuccessStatus(status); | |
| 1535 | + req["contents"] = contents; | |
| 1536 | + } | |
| 1537 | + }; | |
| 1538 | + | |
| 1539 | + if (forceDownload) { | |
| 1540 | + qCDebug(scriptengine_module) << "require.requestScript -- clearing cache for" << modulePath; | |
| 1541 | + scriptCache->deleteScript(modulePath); | |
| 1542 | + } | |
| 1543 | + BatchLoader* loader = new BatchLoader(QList<QUrl>({ modulePath })); | |
| 1544 | + connect(loader, &BatchLoader::finished, this, onload); | |
| 1545 | + connect(this, &QObject::destroyed, loader, &QObject::deleteLater); | |
| 1546 | + // fail faster? (since require() blocks the engine thread while resolving dependencies) | |
| 1547 | + const int MAX_RETRIES = 1; | |
| 1548 | + | |
| 1549 | + loader->start(MAX_RETRIES); | |
| 1550 | + | |
| 1551 | + if (!loader->isFinished()) { | |
| 1552 | + QTimer monitor; | |
| 1553 | + QEventLoop loop; | |
| 1554 | + QObject::connect(loader, &BatchLoader::finished, this, [this, &monitor, &loop]{ | |
| 1555 | + monitor.stop(); | |
| 1556 | + loop.quit(); | |
| 1557 | + }); | |
| 1558 | + | |
| 1559 | + // this helps detect the case where stop() is invoked during the download | |
| 1560 | + // but not seen in time to abort processing in onload()... | |
| 1561 | + connect(&monitor, &QTimer::timeout, this, [this, &loop, &loader]{ | |
| 1562 | + if (isStopping()) { | |
| 1563 | + loop.exit(-1); | |
| 1564 | + } | |
| 1565 | + }); | |
| 1566 | + monitor.start(500); | |
| 1567 | + loop.exec(); | |
| 1568 | + } | |
| 1569 | + loader->deleteLater(); | |
| 1570 | + return req; | |
| 1571 | +} | |
| 1572 | + | |
| 1573 | +// evaluate a pending module object using the fetched source code | |
| 1574 | +QScriptValue ScriptEngine::instantiateModule(const QScriptValue& module, const QString& sourceCode) { | |
| 1575 | + QScriptValue result; | |
| 1576 | + auto modulePath = module.property("filename").toString(); | |
| 1577 | + auto closure = module.property("__closure__"); | |
| 1578 | + | |
| 1579 | + qCDebug(scriptengine_module) << QString("require.instantiateModule: %1 / %2 bytes") | |
| 1580 | + .arg(QUrl(modulePath).fileName()).arg(sourceCode.length()); | |
| 1581 | + | |
| 1582 | + if (module.property("content-type").toString() == "application/json") { | |
| 1583 | + qCDebug(scriptengine_module) << "... parsing as JSON"; | |
| 1584 | + closure.setProperty("__json", sourceCode); | |
| 1585 | + result = evaluateInClosure(closure, { "module.exports = JSON.parse(__json)", modulePath }); | |
| 1586 | + } else { | |
| 1587 | + // scoped vars for consistency with Node.js | |
| 1588 | + closure.setProperty("require", module.property("require")); | |
| 1589 | + closure.setProperty("__filename", modulePath, READONLY_HIDDEN_PROP_FLAGS); | |
| 1590 | + closure.setProperty("__dirname", QString(modulePath).replace(QRegExp("/[^/]*$"), ""), READONLY_HIDDEN_PROP_FLAGS); | |
| 1591 | + result = evaluateInClosure(closure, { sourceCode, modulePath }); | |
| 1592 | + } | |
| 1593 | + maybeEmitUncaughtException(__FUNCTION__); | |
| 1594 | + return result; | |
| 1595 | +} | |
| 1596 | + | |
| 1597 | +// CommonJS/Node.js like require/module support | |
| 1598 | +QScriptValue ScriptEngine::require(const QString& moduleId) { | |
| 1599 | + qCDebug(scriptengine_module) << "ScriptEngine::require(" << moduleId.left(MAX_DEBUG_VALUE_LENGTH) << ")"; | |
| 1600 | + if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) { | |
| 1601 | + return unboundNullValue(); | |
| 1602 | + } | |
| 1603 | + | |
| 1604 | + auto jsRequire = globalObject().property("Script").property("require"); | |
| 1605 | + auto cacheMeta = jsRequire.data(); | |
| 1606 | + auto cache = jsRequire.property("cache"); | |
| 1607 | + auto parent = currentModule(); | |
| 1608 | + | |
| 1609 | + auto throwModuleError = [&](const QString& modulePath, const QScriptValue& error) { | |
| 1610 | + cache.setProperty(modulePath, nullValue()); | |
| 1611 | + if (!error.isNull()) { | |
| 1612 | +#ifdef DEBUG_JS_MODULES | |
| 1613 | + qCWarning(scriptengine_module) << "throwing module error:" << error.toString() << modulePath << error.property("stack").toString(); | |
| 1614 | +#endif | |
| 1615 | + raiseException(error); | |
| 1616 | + } | |
| 1617 | + maybeEmitUncaughtException("module"); | |
| 1618 | + return unboundNullValue(); | |
| 1619 | + }; | |
| 1620 | + | |
| 1621 | + // start by resolving the moduleId into a fully-qualified path/URL | |
| 1622 | + QString modulePath = _requireResolve(moduleId); | |
| 1623 | + if (modulePath.isNull() || hasUncaughtException()) { | |
| 1624 | + // the resolver already threw an exception -- bail early | |
| 1625 | + maybeEmitUncaughtException(__FUNCTION__); | |
| 1626 | + return unboundNullValue(); | |
| 1627 | + } | |
| 1628 | + | |
| 1629 | + // check the resolved path against the cache | |
| 1630 | + auto module = cache.property(modulePath); | |
| 1631 | + | |
| 1632 | + // modules get cached in `Script.require.cache` and (similar to Node.js) users can access it | |
| 1633 | + // to inspect particular entries and invalidate them by deleting the key: | |
| 1634 | + // `delete Script.require.cache[Script.require.resolve(moduleId)];` | |
| 1635 | + | |
| 1636 | + // cacheMeta is just used right now to tell deleted keys apart from undefined ones | |
| 1637 | + bool invalidateCache = module.isUndefined() && cacheMeta.property(moduleId).isValid(); | |
| 1638 | + | |
| 1639 | + // reset the cacheMeta record so invalidation won't apply next time, even if the module fails to load | |
| 1640 | + cacheMeta.setProperty(modulePath, QScriptValue()); | |
| 1641 | + | |
| 1642 | + auto exports = module.property("exports"); | |
| 1643 | + if (!invalidateCache && exports.isObject()) { | |
| 1644 | + // we have found a cached module -- just need to possibly register it with current parent | |
| 1645 | + qCDebug(scriptengine_module) << QString("require - using cached module '%1' for '%2' (loaded: %3)") | |
| 1646 | + .arg(modulePath).arg(moduleId).arg(module.property("loaded").toString()); | |
| 1647 | + registerModuleWithParent(module, parent); | |
| 1648 | + maybeEmitUncaughtException("cached module"); | |
| 1649 | + return exports; | |
| 1650 | + } | |
| 1651 | + | |
| 1652 | + // bootstrap / register new empty module | |
| 1653 | + module = newModule(modulePath, parent); | |
| 1654 | + registerModuleWithParent(module, parent); | |
| 1655 | + | |
| 1656 | + // add it to the cache (this is done early so any cyclic dependencies pick up) | |
| 1657 | + cache.setProperty(modulePath, module); | |
| 1658 | + | |
| 1659 | + // download the module source | |
| 1660 | + auto req = fetchModuleSource(modulePath, invalidateCache); | |
| 1661 | + | |
| 1662 | + if (!req.contains("success") || !req["success"].toBool()) { | |
| 1663 | + auto error = QString("error retrieving script (%1)").arg(req["status"].toString()); | |
| 1664 | + return throwModuleError(modulePath, error); | |
| 1665 | + } | |
| 1666 | + | |
| 1667 | +#if DEBUG_JS_MODULES | |
| 1668 | + qCDebug(scriptengine_module) << "require.loaded: " << | |
| 1669 | + QUrl(req["url"].toString()).fileName() << req["status"].toString(); | |
| 1670 | +#endif | |
| 1671 | + | |
| 1672 | + auto sourceCode = req["contents"].toString(); | |
| 1673 | + | |
| 1674 | + if (QUrl(modulePath).fileName().endsWith(".json", Qt::CaseInsensitive)) { | |
| 1675 | + module.setProperty("content-type", "application/json"); | |
| 1676 | + } else { | |
| 1677 | + module.setProperty("content-type", "application/javascript"); | |
| 1678 | + } | |
| 1679 | + | |
| 1680 | + // evaluate the module | |
| 1681 | + auto result = instantiateModule(module, sourceCode); | |
| 1682 | + | |
| 1683 | + if (result.isError() && !result.strictlyEquals(module.property("exports"))) { | |
| 1684 | + qCWarning(scriptengine_module) << "-- result.isError --" << result.toString(); | |
| 1685 | + return throwModuleError(modulePath, result); | |
| 1686 | + } | |
| 1687 | + | |
| 1688 | + // mark as fully-loaded | |
| 1689 | + module.setProperty("loaded", true, READONLY_PROP_FLAGS); | |
| 1690 | + | |
| 1691 | + // set up a new reference point for detecting cache key deletion | |
| 1692 | + cacheMeta.setProperty(modulePath, module); | |
| 1693 | + | |
| 1694 | + qCDebug(scriptengine_module) << "//ScriptEngine::require(" << moduleId << ")"; | |
| 1695 | + | |
| 1696 | + maybeEmitUncaughtException(__FUNCTION__); | |
| 1697 | + return module.property("exports"); | |
| 1698 | +} | |
| 1699 | + | |
| 1303 | 1700 | // If a callback is specified, the included files will be loaded asynchronously and the callback will be called |
| 1304 | 1701 | // when all of the files have finished loading. |
| 1305 | 1702 | // If no callback is specified, the included files will be loaded synchronously and will block execution until |
| 1306 | 1703 | // all of the files have finished loading. |
| 1307 | 1704 | void ScriptEngine::include(const QStringList& includeFiles, QScriptValue callback) { |
| 1705 | + if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) { | |
| 1706 | + return; | |
| 1707 | + } | |
| 1308 | 1708 | if (DependencyManager::get<ScriptEngines>()->isStopped()) { |
| 1309 | 1709 | scriptWarningMessage("Script.include() while shutting down is ignored... includeFiles:" |
| 1310 | 1710 | + includeFiles.join(",") + "parent script:" + getFilename()); |
| @@ -1367,7 +1767,7 @@ void ScriptEngine::include(const QStringList& includeFiles, QScriptValue callbac · 56 unmodified lines | ||
| 1367 | 1767 | |
| 1368 | 1768 | doWithEnvironment(capturedEntityIdentifier, capturedSandboxURL, operation); |
| 1369 | 1769 | if (hasUncaughtException()) { |
| 1370 | − emit unhandledException(cloneUncaughtException(__FUNCTION__)); | |
| 1770 | + emit unhandledException(cloneUncaughtException("evaluateInclude")); | |
| 1371 | 1771 | clearExceptions(); |
| 1372 | 1772 | } |
| 1373 | 1773 | } else { |
| @@ -1414,6 +1814,9 @@ void ScriptEngine::include(const QString& includeFile, QScriptValue callback) { · 40 unmodified lines | ||
| 1414 | 1814 | // as a stand-alone script. To accomplish this, the ScriptEngine class just emits a signal which |
| 1415 | 1815 | // the Application or other context will connect to in order to know to actually load the script |
| 1416 | 1816 | void ScriptEngine::load(const QString& loadFile) { |
| 1817 | + if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) { | |
| 1818 | + return; | |
| 1819 | + } | |
| 1417 | 1820 | if (DependencyManager::get<ScriptEngines>()->isStopped()) { |
| 1418 | 1821 | scriptWarningMessage("Script.load() while shutting down is ignored... loadFile:" |
| 1419 | 1822 | + loadFile + "parent script:" + getFilename()); |
| @@ -1483,6 +1886,52 @@ void ScriptEngine::updateEntityScriptStatus(const EntityItemID& entityID, const · 63 unmodified lines | ||
| 1483 | 1886 | emit entityScriptDetailsUpdated(); |
| 1484 | 1887 | } |
| 1485 | 1888 | |
| 1889 | +QVariant ScriptEngine::cloneEntityScriptDetails(const EntityItemID& entityID) { | |
| 1890 | + static const QVariant NULL_VARIANT { qVariantFromValue((QObject*)nullptr) }; | |
| 1891 | + QVariantMap map; | |
| 1892 | + if (entityID.isNull()) { | |
| 1893 | + // TODO: find better way to report JS Error across thread/process boundaries | |
| 1894 | + map["isError"] = true; | |
| 1895 | + map["errorInfo"] = "Error: getEntityScriptDetails -- invalid entityID"; | |
| 1896 | + } else { | |
| 1897 | +#ifdef DEBUG_ENTITY_STATES | |
| 1898 | + qDebug() << "cloneEntityScriptDetails" << entityID << QThread::currentThread(); | |
| 1899 | +#endif | |
| 1900 | + EntityScriptDetails scriptDetails; | |
| 1901 | + if (getEntityScriptDetails(entityID, scriptDetails)) { | |
| 1902 | +#ifdef DEBUG_ENTITY_STATES | |
| 1903 | + qDebug() << "gotEntityScriptDetails" << scriptDetails.status << QThread::currentThread(); | |
| 1904 | +#endif | |
| 1905 | + map["isRunning"] = isEntityScriptRunning(entityID); | |
| 1906 | + map["status"] = EntityScriptStatus_::valueToKey(scriptDetails.status).toLower(); | |
| 1907 | + map["errorInfo"] = scriptDetails.errorInfo; | |
| 1908 | + map["entityID"] = entityID.toString(); | |
| 1909 | +#ifdef DEBUG_ENTITY_STATES | |
| 1910 | + { | |
| 1911 | + auto debug = QVariantMap(); | |
| 1912 | + debug["script"] = scriptDetails.scriptText; | |
| 1913 | + debug["scriptObject"] = scriptDetails.scriptObject.toVariant(); | |
| 1914 | + debug["lastModified"] = (qlonglong)scriptDetails.lastModified; | |
| 1915 | + debug["sandboxURL"] = scriptDetails.definingSandboxURL; | |
| 1916 | + map["debug"] = debug; | |
| 1917 | + } | |
| 1918 | +#endif | |
| 1919 | + } else { | |
| 1920 | +#ifdef DEBUG_ENTITY_STATES | |
| 1921 | + qDebug() << "!gotEntityScriptDetails" << QThread::currentThread(); | |
| 1922 | +#endif | |
| 1923 | + map["isError"] = true; | |
| 1924 | + map["errorInfo"] = "Entity script details unavailable"; | |
| 1925 | + map["entityID"] = entityID.toString(); | |
| 1926 | + } | |
| 1927 | + } | |
| 1928 | + return map; | |
| 1929 | +} | |
| 1930 | + | |
| 1931 | +QFuture<QVariant> ScriptEngine::getLocalEntityScriptDetails(const EntityItemID& entityID) { | |
| 1932 | + return QtConcurrent::run(this, &ScriptEngine::cloneEntityScriptDetails, entityID); | |
| 1933 | +} | |
| 1934 | + | |
| 1486 | 1935 | bool ScriptEngine::getEntityScriptDetails(const EntityItemID& entityID, EntityScriptDetails &details) const { |
| 1487 | 1936 | auto it = _entityScripts.constFind(entityID); |
| 1488 | 1937 | if (it == _entityScripts.constEnd()) { |
| @@ -1621,10 +2070,10 @@ void ScriptEngine::loadEntityScript(const EntityItemID& entityID, const QString& · 132 unmodified lines | ||
| 1621 | 2070 | |
| 1622 | 2071 | auto scriptCache = DependencyManager::get<ScriptCache>(); |
| 1623 | 2072 | // note: see EntityTreeRenderer.cpp for shared pointer lifecycle management |
| 1624 | − QWeakPointer<ScriptEngine> weakRef(sharedFromThis()); | |
| 2073 | + QWeakPointer<BaseScriptEngine> weakRef(sharedFromThis()); | |
| 1625 | 2074 | scriptCache->getScriptContents(entityScript, |
| 1626 | 2075 | [this, weakRef, entityScript, entityID](const QString& url, const QString& contents, bool isURL, bool success, const QString& status) { |
| 1627 | − QSharedPointer<ScriptEngine> strongRef(weakRef); | |
| 2076 | + QSharedPointer<BaseScriptEngine> strongRef(weakRef); | |
| 1628 | 2077 | if (!strongRef) { |
| 1629 | 2078 | qCWarning(scriptengine) << "loadEntityScript.contentAvailable -- ScriptEngine was deleted during getScriptContents!!"; |
| 1630 | 2079 | return; |
| @@ -1743,13 +2192,12 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co · 112 unmodified lines | ||
| 1743 | 2192 | timeout.setSingleShot(true); |
| 1744 | 2193 | timeout.start(SANDBOX_TIMEOUT); |
| 1745 | 2194 | connect(&timeout, &QTimer::timeout, [&sandbox, SANDBOX_TIMEOUT, scriptOrURL]{ |
| 1746 | − auto context = sandbox.currentContext(); | |
| 1747 | − if (context) { | |
| 1748 | 2195 | qCDebug(scriptengine) << "ScriptEngine::entityScriptContentAvailable timeout(" << scriptOrURL << ")"; |
| 1749 | 2196 | |
| 1750 | 2197 | // Guard against infinite loops and non-performant code |
| 1751 | − context->throwError(QString("Timed out (entity constructors are limited to %1ms)").arg(SANDBOX_TIMEOUT)); | |
| 1752 | − } | |
| 2198 | + sandbox.raiseException( | |
| 2199 | + sandbox.makeError(QString("Timed out (entity constructors are limited to %1ms)").arg(SANDBOX_TIMEOUT)) | |
| 2200 | + ); | |
| 1753 | 2201 | }); |
| 1754 | 2202 | |
| 1755 | 2203 | testConstructor = sandbox.evaluate(program); |
| @@ -1765,7 +2213,7 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co · 9 unmodified lines | ||
| 1765 | 2213 | if (exception.isError()) { |
| 1766 | 2214 | // create a local copy using makeError to decouple from the sandbox engine |
| 1767 | 2215 | exception = makeError(exception); |
| 1768 | − setError(formatException(exception), EntityScriptStatus::ERROR_RUNNING_SCRIPT); | |
| 2216 | + setError(formatException(exception, _enableExtendedJSExceptions.get()), EntityScriptStatus::ERROR_RUNNING_SCRIPT); | |
| 1769 | 2217 | emit unhandledException(exception); |
| 1770 | 2218 | return; |
| 1771 | 2219 | } |
| @@ -1777,9 +2225,8 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co · 5 unmodified lines | ||
| 1777 | 2225 | testConstructorType = "empty"; |
| 1778 | 2226 | } |
| 1779 | 2227 | QString testConstructorValue = testConstructor.toString(); |
| 1780 | − const int maxTestConstructorValueSize = 80; | |
| 1781 | − if (testConstructorValue.size() > maxTestConstructorValueSize) { | |
| 1782 | − testConstructorValue = testConstructorValue.mid(0, maxTestConstructorValueSize) + "..."; | |
| 2228 | + if (testConstructorValue.size() > MAX_DEBUG_VALUE_LENGTH) { | |
| 2229 | + testConstructorValue = testConstructorValue.mid(0, MAX_DEBUG_VALUE_LENGTH) + "..."; | |
| 1783 | 2230 | } |
| 1784 | 2231 | auto message = QString("failed to load entity script -- expected a function, got %1, %2") |
| 1785 | 2232 | .arg(testConstructorType).arg(testConstructorValue); |
| @@ -1817,7 +2264,7 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co · 31 unmodified lines | ||
| 1817 | 2264 | |
| 1818 | 2265 | if (entityScriptObject.isError()) { |
| 1819 | 2266 | auto exception = entityScriptObject; |
| 1820 | − setError(formatException(exception), EntityScriptStatus::ERROR_RUNNING_SCRIPT); | |
| 2267 | + setError(formatException(exception, _enableExtendedJSExceptions.get()), EntityScriptStatus::ERROR_RUNNING_SCRIPT); | |
| 1821 | 2268 | emit unhandledException(exception); |
| 1822 | 2269 | return; |
| 1823 | 2270 | } |
| @@ -1861,10 +2308,12 @@ void ScriptEngine::unloadEntityScript(const EntityItemID& entityID, bool shouldR · 37 unmodified lines | ||
| 1861 | 2308 | const EntityScriptDetails &oldDetails = _entityScripts[entityID]; |
| 1862 | 2309 | if (isEntityScriptRunning(entityID)) { |
| 1863 | 2310 | callEntityScriptMethod(entityID, "unload"); |
| 1864 | − } else { | |
| 2311 | + } | |
| 2312 | +#ifdef DEBUG_ENTITY_STATES | |
| 2313 | + else { | |
| 1865 | 2314 | qCDebug(scriptengine) << "unload called while !running" << entityID << oldDetails.status; |
| 1866 | 2315 | } |
| 1867 | − | |
| 2316 | +#endif | |
| 1868 | 2317 | if (shouldRemoveFromMap) { |
| 1869 | 2318 | // this was a deleted entity, we've been asked to remove it from the map |
| 1870 | 2319 | _entityScripts.remove(entityID); |
| @@ -1956,10 +2405,7 @@ void ScriptEngine::doWithEnvironment(const EntityItemID& entityID, const QUrl& s · 85 unmodified lines | ||
| 1956 | 2405 | #else |
| 1957 | 2406 | operation(); |
| 1958 | 2407 | #endif |
| 1959 | − if (!isEvaluating() && hasUncaughtException()) { | |
| 1960 | − emit unhandledException(cloneUncaughtException(!entityID.isNull() ? entityID.toString() : __FUNCTION__)); | |
| 1961 | − clearExceptions(); | |
| 1962 | − } | |
| 2408 | + maybeEmitUncaughtException(!entityID.isNull() ? entityID.toString() : __FUNCTION__); | |
| 1963 | 2409 | currentEntityIdentifier = oldIdentifier; |
| 1964 | 2410 | currentSandboxURL = oldSandboxURL; |
| 1965 | 2411 | } |