diff --git a/NativeScript/runtime/BuiltinLoader.cpp b/NativeScript/runtime/BuiltinLoader.cpp index 10913f55..0c99f3c8 100644 --- a/NativeScript/runtime/BuiltinLoader.cpp +++ b/NativeScript/runtime/BuiltinLoader.cpp @@ -124,9 +124,9 @@ MaybeLocal CallBuiltin(Local context, BuiltinId id, } // Snapshot of the intrinsics, taken the first time any builtin runs in this -// isolate — during runtime init, before user code can replace a global. Later -// builtins (smart-stringify compiles lazily, on the first object logged) get -// the same pristine snapshot. +// isolate — during runtime init, before user code can replace a global. +// Builtins compiled later in the isolate's life get the same pristine +// snapshot. MaybeLocal GetPrimordials(Local context) { Isolate* isolate = v8::Isolate::GetCurrent(); std::shared_ptr cache = Caches::Get(isolate); diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 25852103..647b4214 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -152,7 +152,8 @@ class Caches { std::unique_ptr>(nullptr); std::unique_ptr> WeakRefClearFunc = std::unique_ptr>(nullptr); - std::unique_ptr> SmartJSONStringifyFunc = + // console formatter (internal/inspect.js), initialized by Console::Init. + std::unique_ptr> InspectFunc = std::unique_ptr>(nullptr); std::unique_ptr> InteropReferenceCtorFunc = std::unique_ptr>(nullptr); diff --git a/NativeScript/runtime/Console.cpp b/NativeScript/runtime/Console.cpp index c0391790..0ca65a89 100644 --- a/NativeScript/runtime/Console.cpp +++ b/NativeScript/runtime/Console.cpp @@ -6,7 +6,9 @@ #include #include +#include "BuiltinLoader.h" #include "Caches.h" +#include "DataWrapper.h" #include "Helpers.h" #include "NativeScriptException.h" #include "RuntimeConfig.h" @@ -35,6 +37,8 @@ void Console::Init(Local context) { Console::AttachLogFunction(context, console, "time", TimeCallback); Console::AttachLogFunction(context, console, "timeEnd", TimeEndCallback); + Console::InitInspect(context); + Local global = context->Global(); PropertyAttribute readOnlyFlags = static_cast( PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); @@ -155,7 +159,6 @@ void Console::DirCallback(const FunctionCallbackInfo& args) { return; } - int argsLen = args.Length(); Isolate* isolate = args.GetIsolate(); Local context = isolate->GetCurrentContext(); @@ -163,66 +166,13 @@ void Console::DirCallback(const FunctionCallbackInfo& args) { std::string scriptUrl = tns::GetCurrentScriptUrl(isolate); ss << scriptUrl << ":"; - if (argsLen > 0) { - if (!args[0]->IsObject()) { - std::string logString = BuildStringFromArgs(args); - ss << " " << logString; - } else { - ss << std::endl << "==== object dump start ====" << std::endl; - Local argObject = args[0].As(); - - Local propNames; - bool success = argObject->GetPropertyNames(context).ToLocal(&propNames); - tns::Assert(success, isolate); - uint32_t propertiesLength = propNames->Length(); - for (uint32_t i = 0; i < propertiesLength; i++) { - Local propertyName = propNames->Get(context, i).ToLocalChecked(); - Local propertyValue; - bool success = - argObject->Get(context, propertyName).ToLocal(&propertyValue); - if (!success || propertyValue.IsEmpty() || - propertyValue->IsUndefined()) { - continue; - } - - bool propIsFunction = propertyValue->IsFunction(); - - ss << tns::ToString(isolate, - propertyName->ToString(context).ToLocalChecked()) - << ": "; - - if (propIsFunction) { - ss << "()"; - } else if (propertyValue->IsArray()) { - Local stringResult = - BuildStringFromArg(context, propertyValue); - std::string jsonStringifiedArray = - tns::ToString(isolate, stringResult); - ss << jsonStringifiedArray; - } else if (propertyValue->IsObject()) { - Local obj = propertyValue->ToObject(context).ToLocalChecked(); - Local objString = TransformJSObject(obj); - std::string jsonStringifiedObject = tns::ToString(isolate, objString); - // if object prints out as the error string for circular references, - // replace with #CR instead for brevity - if (jsonStringifiedObject.find("circular structure") != - std::string::npos) { - jsonStringifiedObject = "#CR"; - } - ss << jsonStringifiedObject; - } else { - ss << "\"" - << tns::ToString( - isolate, - propertyValue->ToDetailString(context).ToLocalChecked()) - << "\""; - } - - ss << std::endl; - } - - ss << "==== object dump end ====" << std::endl; - } + if (args.Length() > 0 && args[0]->IsObject()) { + ss << std::endl << "==== object dump start ====" << std::endl; + ss << tns::ToString(isolate, Console::InspectValue(context, args[0], 4)) + << std::endl; + ss << "==== object dump end ====" << std::endl; + } else if (args.Length() > 0) { + ss << " " << BuildStringFromArgs(args); } else { ss << ""; } @@ -348,88 +298,104 @@ std::string Console::BuildStringFromArgs( const Local Console::BuildStringFromArg(Local context, const Local& val) { Isolate* isolate = v8::Isolate::GetCurrent(); - Local argString; - if (val->IsFunction()) { - bool success = val->ToDetailString(context).ToLocal(&argString); - tns::Assert(success, isolate); - } else if (val->IsArray()) { - Local cachedSelf = val; - Local array = val->ToObject(context).ToLocalChecked(); - Local arrayEntryKeys = - array->GetPropertyNames(context).ToLocalChecked(); - - uint32_t arrayLength = arrayEntryKeys->Length(); - - argString = tns::ToV8String(isolate, "["); - - for (int i = 0; i < arrayLength; i++) { - Local propertyName = - arrayEntryKeys->Get(context, i).ToLocalChecked(); - - Local propertyValue = - array->Get(context, propertyName).ToLocalChecked(); - - // avoid bottomless recursion with cyclic reference to the same array - if (propertyValue->StrictEquals(cachedSelf)) { - argString = v8::String::Concat(isolate, argString, - tns::ToV8String(isolate, "[Circular]")); - continue; - } + // Top-level strings print raw (console.log("hi") -> hi); everything else + // that can carry structure goes through the inspect builtin. + if (val->IsString()) { + return val.As(); + } + if (val->IsObject() || val->IsFunction()) { + return Console::InspectValue(context, val); + } - Local objectString = - BuildStringFromArg(context, propertyValue); + Local argString; + bool success = val->ToDetailString(context).ToLocal(&argString); + tns::Assert(success, isolate); + return argString; +} - argString = v8::String::Concat(isolate, argString, objectString); +static void GetNativeWrapperHintCallback( + const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1) { + return; + } + std::string hint = tns::GetNativeWrapperHint(isolate, info[0]); + if (!hint.empty()) { + info.GetReturnValue().Set(tns::ToV8String(isolate, hint)); + } +} - if (i != arrayLength - 1) { - argString = v8::String::Concat(isolate, argString, - tns::ToV8String(isolate, ", ")); - } - } +void Console::InitInspect(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); - argString = - v8::String::Concat(isolate, argString, tns::ToV8String(isolate, "]")); - } else if (val->IsObject()) { - Local obj = val.As(); + Local hintFunc; + if (!v8::Function::New(context, GetNativeWrapperHintCallback) + .ToLocal(&hintFunc)) { + Log("Warning: Console failed to create the native-hint binding"); + return; + } + Local binding = Object::New(isolate); + if (!binding + ->Set(context, tns::ToV8String(isolate, "getNativeWrapperHint"), + hintFunc) + .FromMaybe(false)) { + Log("Warning: Console failed to populate the inspect binding"); + return; + } - argString = TransformJSObject(obj); - } else { - bool success = - val->ToDetailString(isolate->GetCurrentContext()).ToLocal(&argString); - tns::Assert(success, isolate); + TryCatch tc(isolate); + Local result; + if (!BuiltinLoader::RunBuiltin(context, BuiltinId::kInspect, binding) + .ToLocal(&result) || + !result->IsFunction()) { + if (tc.HasCaught()) { + tns::LogError(isolate, tc); + } + Log("Warning: Console failed to initialize the inspect builtin"); + return; } - return argString; + Caches::Get(isolate)->InspectFunc = + std::make_unique>(isolate, + result.As()); } -const Local Console::TransformJSObject(Local object) { - Local context; - bool success = - object->GetCreationContext(v8::Isolate::GetCurrent()).ToLocal(&context); - tns::Assert(success); +Local Console::InspectValue(Local context, + const Local& val, int depth) { Isolate* isolate = v8::Isolate::GetCurrent(); - Local value; - { + auto cache = Caches::Get(isolate); + + if (cache->InspectFunc != nullptr) { + Local inspect = cache->InspectFunc->Get(isolate); + Local arg = val; + Local args[2]; + int argc = 1; + args[0] = arg; + if (depth >= 0) { + Local options = Object::New(isolate); + if (options + ->Set(context, tns::ToV8String(isolate, "depth"), + v8::Number::New(isolate, depth)) + .FromMaybe(false)) { + args[1] = options; + argc = 2; + } + } TryCatch tc(isolate); - bool success = object->ToString(context).ToLocal(&value); - if (!success) { - return tns::ToV8String(isolate, ""); + Local result; + if (inspect->Call(context, v8::Undefined(isolate), argc, args) + .ToLocal(&result) && + result->IsString()) { + return result.As(); } } - Local objToString = value.As(); - - Local resultString; - bool hasCustomToStringImplementation = - tns::ToString(isolate, objToString).find("[object Object]") == - std::string::npos; - if (hasCustomToStringImplementation) { - resultString = objToString; - } else { - resultString = tns::JsonStringifyObject(context, object); + // Init failed or the formatter threw: degrade to V8's own short description. + Local fallback; + if (val->ToDetailString(context).ToLocal(&fallback)) { + return fallback; } - - return resultString; + return v8::String::Empty(isolate); } v8_inspector::ConsoleAPIType Console::VerbosityToInspectorMethod( diff --git a/NativeScript/runtime/Console.h b/NativeScript/runtime/Console.h index f4b23a7b..0ddb59a8 100644 --- a/NativeScript/runtime/Console.h +++ b/NativeScript/runtime/Console.h @@ -1,38 +1,51 @@ #ifndef Console_h #define Console_h +#include + #include "Common.h" #include "JSV8InspectorClient.h" -#include namespace tns { class Console { -public: - static void Init(v8::Local context); - static void AttachInspectorClient(v8_inspector::JsV8InspectorClient* inspector); - static void DetachInspectorClient(); -private: - using ConsoleAPIType = v8_inspector::ConsoleAPIType; - - static void AttachLogFunction(v8::Local context, v8::Local console, const std::string name, v8::FunctionCallback callback = Console::LogCallback); - static void LogCallback(const v8::FunctionCallbackInfo& args); - static void AssertCallback(const v8::FunctionCallbackInfo& args); - static void DirCallback(const v8::FunctionCallbackInfo& args); - static void TimeCallback(const v8::FunctionCallbackInfo& args); - static void TimeEndCallback(const v8::FunctionCallbackInfo& args); - static std::string BuildStringFromArgs(const v8::FunctionCallbackInfo& args, int startingIndex = 0); - static const v8::Local BuildStringFromArg(v8::Local context, const v8::Local& val); - static const v8::Local TransformJSObject(v8::Local object); - static ConsoleAPIType VerbosityToInspectorMethod(const std::string level); - - static void SendToDevToolsFrontEnd(ConsoleAPIType method, - const v8::FunctionCallbackInfo& args); - static void SendToDevToolsFrontEnd(v8::Isolate* isolate, ConsoleAPIType method, const std::string& msg); - - static v8_inspector::JsV8InspectorClient* inspector; + public: + static void Init(v8::Local context); + static void AttachInspectorClient( + v8_inspector::JsV8InspectorClient* inspector); + static void DetachInspectorClient(); + + private: + using ConsoleAPIType = v8_inspector::ConsoleAPIType; + + static void AttachLogFunction( + v8::Local context, v8::Local console, + const std::string name, + v8::FunctionCallback callback = Console::LogCallback); + static void LogCallback(const v8::FunctionCallbackInfo& args); + static void AssertCallback(const v8::FunctionCallbackInfo& args); + static void DirCallback(const v8::FunctionCallbackInfo& args); + static void TimeCallback(const v8::FunctionCallbackInfo& args); + static void TimeEndCallback(const v8::FunctionCallbackInfo& args); + static std::string BuildStringFromArgs( + const v8::FunctionCallbackInfo& args, int startingIndex = 0); + static const v8::Local BuildStringFromArg( + v8::Local context, const v8::Local& val); + static v8::Local InspectValue(v8::Local context, + const v8::Local& val, + int depth = -1); + static void InitInspect(v8::Local context); + static ConsoleAPIType VerbosityToInspectorMethod(const std::string level); + + static void SendToDevToolsFrontEnd( + ConsoleAPIType method, const v8::FunctionCallbackInfo& args); + static void SendToDevToolsFrontEnd(v8::Isolate* isolate, + ConsoleAPIType method, + const std::string& msg); + + static v8_inspector::JsV8InspectorClient* inspector; }; -} +} // namespace tns #endif /* Console_h */ diff --git a/NativeScript/runtime/Helpers.h b/NativeScript/runtime/Helpers.h index 8f2d5834..be9f8bd2 100644 --- a/NativeScript/runtime/Helpers.h +++ b/NativeScript/runtime/Helpers.h @@ -301,10 +301,9 @@ static inline void TNS_FormatAndLog(const char* fmt, ...) { // Keep the existing Log(...) macro name for call-site compatibility. #define Log(...) tns::TNS_FormatAndLog(__VA_ARGS__) -v8::Local JsonStringifyObject(v8::Local context, - v8::Local value, - bool handleCircularReferences = true); -v8::Local GetSmartJSONStringifyFunction(v8::Isolate* isolate); +// Short identification for objects backed by a native wrapper (ObjC class +// name etc.); empty when the value is a plain JS object. Never runs JS. +std::string GetNativeWrapperHint(v8::Isolate* isolate, const v8::Local& value); std::string ReplaceAll(const std::string source, std::string find, std::string replacement); diff --git a/NativeScript/runtime/Helpers.mm b/NativeScript/runtime/Helpers.mm index 64f47fb8..dda102a2 100644 --- a/NativeScript/runtime/Helpers.mm +++ b/NativeScript/runtime/Helpers.mm @@ -4,13 +4,13 @@ #include #include #include +#include #include #include #include #include #include #include -#include "BuiltinLoader.h" #include "Caches.h" #include "NativeScriptException.h" #include "Runtime.h" @@ -447,71 +447,36 @@ Log(@"%s", stackTraceStr.c_str()); } -Local tns::JsonStringifyObject(Local context, Local value, - bool handleCircularReferences) { - Isolate* isolate = v8::Isolate::GetCurrent(); - if (value.IsEmpty()) { - return v8::String::Empty(isolate); +std::string tns::GetNativeWrapperHint(Isolate* isolate, const Local& value) { + BaseDataWrapper* wrapper = tns::GetValue(isolate, value); + if (wrapper == nullptr) { + return std::string(); } - if (handleCircularReferences) { - Local smartJSONStringifyFunction = tns::GetSmartJSONStringifyFunction(isolate); - - if (!smartJSONStringifyFunction.IsEmpty()) { - if (value->IsObject()) { - Local resultValue; - TryCatch tc(isolate); - - Local args[] = {value->ToObject(context).ToLocalChecked()}; - bool success = smartJSONStringifyFunction->Call(context, v8::Undefined(isolate), 1, args) - .ToLocal(&resultValue); - - if (success && !tc.HasCaught()) { - return resultValue->ToString(context).ToLocalChecked(); - } - } + switch (wrapper->Type()) { + case WrapperType::ObjCObject: { + id data = static_cast(wrapper)->Data(); + return data != nil ? std::string(object_getClassName(data)) : "ObjCObject"; } + case WrapperType::ObjCClass: { + Class klass = static_cast(wrapper)->Klass(); + return klass != nil ? "class " + std::string(class_getName(klass)) : "ObjCClass"; + } + case WrapperType::ObjCProtocol: { + Protocol* proto = static_cast(wrapper)->Proto(); + return proto != nil ? "protocol " + std::string(protocol_getName(proto)) : "ObjCProtocol"; + } + case WrapperType::Pointer: + return "Pointer"; + case WrapperType::Block: + return "Block"; + case WrapperType::Function: + return "NativeFunction"; + default: + // Remaining wrapper kinds (structs, references, enums, ...) format fine + // through the ordinary object path. + return std::string(); } - - Local resultString; - TryCatch tc(isolate); - bool success = v8::JSON::Stringify(context, value->ToObject(context).ToLocalChecked()) - .ToLocal(&resultString); - - if (!success && tc.HasCaught()) { - tns::LogError(isolate, tc); - return Local(); - } - - return resultString; -} - -Local tns::GetSmartJSONStringifyFunction(Isolate* isolate) { - std::shared_ptr caches = Caches::Get(isolate); - if (caches->SmartJSONStringifyFunc != nullptr) { - return caches->SmartJSONStringifyFunc->Get(isolate); - } - - Local context = isolate->GetCurrentContext(); - - TryCatch tc(isolate); - Local result; - bool success = BuiltinLoader::RunBuiltin(context, BuiltinId::kSmartStringify).ToLocal(&result); - if (!success && tc.HasCaught()) { - tns::LogError(isolate, tc); - } - tns::Assert(success, isolate); - - if (result.IsEmpty() || !result->IsFunction()) { - return Local(); - } - - Local smartStringifyFunction = result.As(); - - caches->SmartJSONStringifyFunc = - std::make_unique>(isolate, smartStringifyFunction); - - return smartStringifyFunction; } std::string tns::ReplaceAll(const std::string source, std::string find, std::string replacement) { diff --git a/NativeScript/runtime/js/README.md b/NativeScript/runtime/js/README.md index da14bb60..95382b6d 100644 --- a/NativeScript/runtime/js/README.md +++ b/NativeScript/runtime/js/README.md @@ -31,6 +31,10 @@ module.exports = somethingTheCallSiteNeeds; every tool that isn't reading this repo's ESLint config (editors' TS server, prettier, review bots) rejects the file as invalid JavaScript. - Strict mode is per-file: start the file with `"use strict";` to opt in. +- `inspect.js` is the console formatter (util.inspect-lite, exposed as the + internal `__inspect` global): budgeted output, no getter invocation, + tamper-immune via primordials. Console routes all object formatting + through it. - Destructure `binding` and `primordials` once, at the top of the file, so the file's dependencies are visible and greppable. @@ -59,9 +63,8 @@ module.exports = somethingTheCallSiteNeeds; `primordials.js` runs first in every isolate — lazily, on the first `RunBuiltin` call, which happens during runtime init — and its frozen, null-prototype export is cached per isolate (`Caches::Primordials`) and -handed to every other builtin. Builtins that compile late (`smart-stringify` -is compiled on the first object logged) therefore still see intrinsics as they -were before user code ran. +handed to every other builtin, so a builtin that compiles later in the +isolate's life still sees intrinsics as they were before user code ran. Naming follows Node: statics keep their path (`JSONStringify`, `ObjectDefineProperty`), instance methods are **uncurried** so the receiver diff --git a/NativeScript/runtime/js/inspect.js b/NativeScript/runtime/js/inspect.js new file mode 100644 index 00000000..772798e9 --- /dev/null +++ b/NativeScript/runtime/js/inspect.js @@ -0,0 +1,411 @@ +"use strict"; + +// Terminal formatter for console.* (util.inspect-lite). Everything here runs +// while user code may have tampered any prototype, so all intrinsic access +// goes through primordials, brands come from Object.prototype.toString rather +// than constructors, and getters are NEVER invoked. Output is budgeted: depth, +// per-collection entry caps, string caps, and a hard total-size cap make it +// impossible for a single console.log to hang the app on a huge graph. + +const { getNativeWrapperHint } = binding; +const { + ArrayBufferIsView, + ArrayBufferPrototypeGetByteLength, + ArrayIsArray, + ArrayPrototypePush, + DataViewPrototypeGetByteLength, + DatePrototypeGetTime, + DatePrototypeToISOString, + FunctionPrototypeToString, + JSONStringify, + MapIteratorPrototypeNext, + MapPrototypeEntries, + ObjectDefineProperty, + ObjectGetOwnPropertyDescriptor, + ObjectGetOwnPropertySymbols, + ObjectGetPrototypeOf, + ObjectIs, + ObjectKeys, + FunctionPrototypeCall, + ObjectPrototype, + ObjectPrototypePropertyIsEnumerable, + ObjectPrototypeToString, + RegExpPrototypeTest, + RegExpPrototypeToString, + Set, + SetIteratorPrototypeNext, + SetPrototypeAdd, + SetPrototypeDelete, + SetPrototypeHas, + SetPrototypeValues, + String, + StringPrototypeIndexOf, + StringPrototypeSlice, + SymbolPrototypeToString, + TypedArrayPrototypeGetLength, +} = primordials; + +const DEFAULT_DEPTH = 2; +const MAX_ENTRIES = 100; // array elements / object keys / map+set entries +const MAX_STRING = 10000; // characters of a single string value +const MAX_TOTAL = 16384; // hard cap for one inspect() result + +// Thrown to unwind out of a deep graph the moment the total budget is spent; +// never escapes inspect(). +const BUDGET_EXHAUSTED = { budget: true }; + +function inspect(value, options) { + const ctx = { + depth: options && typeof options.depth === "number" ? options.depth : DEFAULT_DEPTH, + remaining: MAX_TOTAL, + // In-progress ancestors only (entries are removed on exit), so shared + // acyclic references print normally and only true cycles say [Circular]. + ancestors: new Set(), + truncated: false, + }; + let result; + try { + result = formatValue(ctx, value, 0); + } catch (e) { + if (e === BUDGET_EXHAUSTED) { + ctx.truncated = true; + result = ctx.out !== undefined ? ctx.out : ""; + } else { + // A formatter bug must never take the log call down with it. + try { + result = "[inspect failed: " + safeString(e) + "]"; + } catch (ignored) { + result = "[inspect failed]"; + } + } + } + if (ctx.truncated) { + result = result + "... (output truncated)"; + } + return result; +} + +function spend(ctx, text) { + ctx.remaining -= text.length; + if (ctx.remaining < 0) { + ctx.out = StringPrototypeSlice(text, 0, text.length + ctx.remaining); + throw BUDGET_EXHAUSTED; + } + return text; +} + +function safeString(value) { + try { + return String(value); + } catch (ignored) { + return "?"; + } +} + +function quoteString(ctx, str) { + let clipped = str; + let suffix = ""; + if (clipped.length > MAX_STRING) { + clipped = StringPrototypeSlice(clipped, 0, MAX_STRING); + suffix = "... " + (str.length - MAX_STRING) + " more characters"; + } + // JSONStringify escapes quotes/control characters; a string exotic enough to + // defeat it (lone surrogates are fine) just falls back to raw. + let quoted; + try { + quoted = JSONStringify(clipped); + } catch (ignored) { + quoted = "'" + clipped + "'"; + } + return quoted + suffix; +} + +function formatValue(ctx, value, depth) { + // Primitives. + if (value === null) return "null"; + const type = typeof value; + if (type === "undefined") return "undefined"; + if (type === "string") return quoteString(ctx, value); + if (type === "number") return ObjectIs(value, -0) ? "-0" : String(value); + if (type === "boolean") return value ? "true" : "false"; + if (type === "bigint") return String(value) + "n"; + if (type === "symbol") return SymbolPrototypeToString(value); + + if (type === "function") return formatFunction(value); + + // Objects. Native wrappers first: never walk into a native-backed graph. + const hint = getNativeWrapperHint(value); + if (hint !== undefined) { + return "[" + hint + "]"; + } + + if (SetPrototypeHas(ctx.ancestors, value)) { + return "[Circular]"; + } + if (depth > ctx.depth) { + const brand = ObjectPrototypeToString(value); + return brand === "[object Object]" ? "[Object]" : "[" + StringPrototypeSlice(brand, 8, -1) + "]"; + } + + SetPrototypeAdd(ctx.ancestors, value); + try { + return formatObject(ctx, value, depth); + } finally { + SetPrototypeDelete(ctx.ancestors, value); + } +} + +function formatFunction(fn) { + let name = ""; + const desc = ObjectGetOwnPropertyDescriptor(fn, "name"); + if (desc !== undefined && typeof desc.value === "string") { + name = desc.value; + } + let isClass = false; + try { + const src = FunctionPrototypeToString(fn); + isClass = StringPrototypeIndexOf(src, "class") === 0; + } catch (ignored) { + // Some callables (bound functions render fine; revoked proxies do not) + // refuse toString; treat them as plain functions. + } + if (isClass) { + return name === "" ? "[class (anonymous)]" : "[class " + name + "]"; + } + return name === "" ? "[Function (anonymous)]" : "[Function: " + name + "]"; +} + +function formatObject(ctx, value, depth) { + const brand = ObjectPrototypeToString(value); + + if (ArrayIsArray(value)) { + return formatArray(ctx, value, depth); + } + if (brand === "[object Map]") { + return formatMapLike(ctx, value, depth, true); + } + if (brand === "[object Set]") { + return formatMapLike(ctx, value, depth, false); + } + if (brand === "[object Date]") { + const time = DatePrototypeGetTime(value); + return time !== time ? "Invalid Date" : DatePrototypeToISOString(value); + } + if (brand === "[object RegExp]") { + return RegExpPrototypeToString(value); + } + if (brand === "[object Error]") { + return formatError(ctx, value); + } + if (brand === "[object Promise]") { + return "Promise {}"; + } + if (ArrayBufferIsView(value)) { + // TypedArray/DataView: brand carries the concrete type name; sizes come + // from the captured accessor getters, not the (tamperable) prototype. + const name = StringPrototypeSlice(brand, 8, -1); + let size; + try { + size = brand === "[object DataView]" + ? DataViewPrototypeGetByteLength(value) + : TypedArrayPrototypeGetLength(value); + } catch (ignored) { + return name; + } + return name + "(" + size + ")"; + } + if (brand === "[object ArrayBuffer]" || brand === "[object SharedArrayBuffer]") { + let size; + try { + size = ArrayBufferPrototypeGetByteLength(value); + } catch (ignored) { + return StringPrototypeSlice(brand, 8, -1); + } + return StringPrototypeSlice(brand, 8, -1) + "(" + size + ")"; + } + + // NativeScript core (ViewBase, Observable, ...) and app classes override + // toString for short debug forms; the old console honored that, so a custom + // toString wins over structural rendering. This is the second deliberate + // exception to the no-user-code rule (with error.stack), guarded and capped. + const customToString = findCustomToString(value); + if (customToString !== undefined) { + try { + const str = FunctionPrototypeCall(customToString, value); + if (typeof str === "string" && str !== "") { + return spend(ctx, str.length > MAX_STRING ? StringPrototypeSlice(str, 0, MAX_STRING) : str); + } + } catch (ignored) { + } + } + + return formatPlainObject(ctx, value, depth); +} + +// A toString override that is NOT Object.prototype's: own property first, then +// up the chain, stopping at Object.prototype. Data properties only — a +// toString defined as an accessor is not worth invoking a getter for. +function findCustomToString(value) { + let target = value; + for (let i = 0; target !== null && target !== ObjectPrototype && i < 8; i++) { + const desc = ObjectGetOwnPropertyDescriptor(target, "toString"); + if (desc !== undefined) { + return typeof desc.value === "function" ? desc.value : undefined; + } + target = ObjectGetPrototypeOf(target); + } + return undefined; +} + +function formatError(ctx, err) { + // Exception to the no-getters rule: V8 materializes `stack` through a lazy + // own accessor, and the stack is the whole point of printing an error. The + // guarded read carries the same exposure as every other error-reporting + // path in the runtime that touches `.stack`. + try { + const stack = err.stack; + if (typeof stack === "string" && stack !== "") { + return spend(ctx, stack); + } + } catch (ignored) { + } + const messageDesc = ObjectGetOwnPropertyDescriptor(err, "message"); + const message = messageDesc !== undefined && typeof messageDesc.value === "string" + ? messageDesc.value : ""; + let name = "Error"; + const nameDesc = ObjectGetOwnPropertyDescriptor(err, "name"); + if (nameDesc !== undefined && typeof nameDesc.value === "string") { + name = nameDesc.value; + } + return message === "" ? name : name + ": " + message; +} + +function formatArray(ctx, arr, depth) { + const parts = []; + const len = arr.length; + const shown = len > MAX_ENTRIES ? MAX_ENTRIES : len; + let emptyRun = 0; + for (let i = 0; i < shown; i++) { + const desc = ObjectGetOwnPropertyDescriptor(arr, i); + if (desc === undefined) { + emptyRun++; + continue; + } + if (emptyRun > 0) { + ArrayPrototypePush(parts, "<" + emptyRun + " empty items>"); + emptyRun = 0; + } + ArrayPrototypePush(parts, formatProperty(ctx, desc, depth)); + } + if (emptyRun > 0) { + ArrayPrototypePush(parts, "<" + emptyRun + " empty items>"); + } + if (len > shown) { + ArrayPrototypePush(parts, "... " + (len - shown) + " more items"); + } + return spend(ctx, parts.length === 0 ? "[]" : "[ " + join(parts) + " ]"); +} + +function formatMapLike(ctx, coll, depth, isMap) { + const parts = []; + let count = 0; + let total = 0; + const iter = isMap ? MapPrototypeEntries(coll) : SetPrototypeValues(coll); + const next = isMap ? MapIteratorPrototypeNext : SetIteratorPrototypeNext; + for (;;) { + const step = next(iter); + if (step.done) break; + total++; + if (count < MAX_ENTRIES) { + count++; + if (isMap) { + const entry = step.value; + ArrayPrototypePush(parts, + formatValue(ctx, entry[0], depth + 1) + " => " + formatValue(ctx, entry[1], depth + 1)); + } else { + ArrayPrototypePush(parts, formatValue(ctx, step.value, depth + 1)); + } + } + } + if (total > count) { + ArrayPrototypePush(parts, "... " + (total - count) + " more items"); + } + const label = (isMap ? "Map(" : "Set(") + total + ")"; + return spend(ctx, parts.length === 0 ? label + " {}" : label + " { " + join(parts) + " }"); +} + +function formatPlainObject(ctx, value, depth) { + const parts = []; + const keys = ObjectKeys(value); + const shown = keys.length > MAX_ENTRIES ? MAX_ENTRIES : keys.length; + for (let i = 0; i < shown; i++) { + const key = keys[i]; + const desc = ObjectGetOwnPropertyDescriptor(value, key); + if (desc === undefined) continue; + ArrayPrototypePush(parts, formatKey(key) + ": " + formatProperty(ctx, desc, depth)); + } + if (keys.length > shown) { + ArrayPrototypePush(parts, "... " + (keys.length - shown) + " more properties"); + } + const symbols = ObjectGetOwnPropertySymbols(value); + for (let i = 0; i < symbols.length && i < MAX_ENTRIES; i++) { + if (ObjectPrototypePropertyIsEnumerable(value, symbols[i])) { + const desc = ObjectGetOwnPropertyDescriptor(value, symbols[i]); + ArrayPrototypePush(parts, + "[" + SymbolPrototypeToString(symbols[i]) + "]: " + formatProperty(ctx, desc, depth)); + } + } + + let prefix = ""; + const proto = ObjectGetPrototypeOf(value); + if (proto === null) { + prefix = "[Object: null prototype] "; + } else { + const ctorDesc = ObjectGetOwnPropertyDescriptor(proto, "constructor"); + const ctor = ctorDesc !== undefined ? ctorDesc.value : undefined; + if (typeof ctor === "function") { + const nameDesc = ObjectGetOwnPropertyDescriptor(ctor, "name"); + const name = nameDesc !== undefined ? nameDesc.value : undefined; + if (typeof name === "string" && name !== "" && name !== "Object") { + prefix = name + " "; + } + } + } + + return spend(ctx, parts.length === 0 ? prefix + "{}" : prefix + "{ " + join(parts) + " }"); +} + +// A log call must never execute user code: accessors render as tags. +function formatProperty(ctx, desc, depth) { + if (desc.get !== undefined) { + return desc.set !== undefined ? "[Getter/Setter]" : "[Getter]"; + } + if (desc.set !== undefined) { + return "[Setter]"; + } + return formatValue(ctx, desc.value, depth + 1); +} + +const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +function formatKey(key) { + return RegExpPrototypeTest(IDENT, key) ? key : "'" + key + "'"; +} + +function join(parts) { + let out = ""; + for (let i = 0; i < parts.length; i++) { + out += (i === 0 ? "" : ", ") + parts[i]; + } + return out; +} + +// Testability + app-level escape hatch (util.inspect-lite); installed eagerly +// by Console::Init running this builtin. +ObjectDefineProperty(global, "__inspect", { + writable: false, + enumerable: false, + configurable: false, + value: inspect, +}); + +module.exports = inspect; diff --git a/NativeScript/runtime/js/primordials.js b/NativeScript/runtime/js/primordials.js index 56d8ca71..e8db4f68 100644 --- a/NativeScript/runtime/js/primordials.js +++ b/NativeScript/runtime/js/primordials.js @@ -24,17 +24,26 @@ const intrinsics = { Error, Map, Proxy, + Set, String, TypeError, SymbolHasInstance: Symbol.hasInstance, + // Namespaces / prototypes. + ObjectPrototype: Object.prototype, + // Statics. + ArrayBufferIsView: ArrayBuffer.isView, + ArrayIsArray: Array.isArray, JSONStringify: JSON.stringify, ObjectAssign: Object.assign, ObjectCreate: Object.create, ObjectDefineProperty: Object.defineProperty, ObjectFreeze: Object.freeze, ObjectGetOwnPropertyDescriptor: Object.getOwnPropertyDescriptor, + ObjectGetOwnPropertySymbols: Object.getOwnPropertySymbols, + ObjectGetPrototypeOf: Object.getPrototypeOf, + ObjectIs: Object.is, ObjectKeys: Object.keys, ObjectSetPrototypeOf: Object.setPrototypeOf, ReflectConstruct: Reflect.construct, @@ -49,12 +58,43 @@ const intrinsics = { FunctionPrototypeBind: uncurryThis(FunctionPrototypeBind), FunctionPrototypeCall: uncurryThis(FunctionPrototypeCall), FunctionPrototypeToString: uncurryThis(Function.prototype.toString), + DatePrototypeGetTime: uncurryThis(Date.prototype.getTime), + DatePrototypeToISOString: uncurryThis(Date.prototype.toISOString), MapPrototypeDelete: uncurryThis(Map.prototype.delete), + MapPrototypeEntries: uncurryThis(Map.prototype.entries), MapPrototypeGet: uncurryThis(Map.prototype.get), MapPrototypeSet: uncurryThis(Map.prototype.set), ObjectPrototypeHasOwnProperty: uncurryThis(Object.prototype.hasOwnProperty), + ObjectPrototypePropertyIsEnumerable: uncurryThis(Object.prototype.propertyIsEnumerable), + ObjectPrototypeToString: uncurryThis(Object.prototype.toString), + RegExpPrototypeTest: uncurryThis(RegExp.prototype.test), + RegExpPrototypeToString: uncurryThis(RegExp.prototype.toString), + SetPrototypeAdd: uncurryThis(Set.prototype.add), + SetPrototypeDelete: uncurryThis(Set.prototype.delete), + SetPrototypeHas: uncurryThis(Set.prototype.has), + SetPrototypeValues: uncurryThis(Set.prototype.values), StringPrototypeIndexOf: uncurryThis(String.prototype.indexOf), + StringPrototypeSlice: uncurryThis(String.prototype.slice), StringPrototypeToLowerCase: uncurryThis(String.prototype.toLowerCase), + SymbolPrototypeToString: uncurryThis(Symbol.prototype.toString), + + // Iterator-protocol escape hatches: the captured `next` of the live map/set + // iterator prototypes, so entries can be walked with early exit even after + // user code tampers %MapIteratorPrototype%/%SetIteratorPrototype%. + MapIteratorPrototypeNext: uncurryThis( + Object.getPrototypeOf(new Map()[Symbol.iterator]()).next), + SetIteratorPrototypeNext: uncurryThis( + Object.getPrototypeOf(new Set()[Symbol.iterator]()).next), + + // Captured accessor getters: reading .length/.byteLength off a view via the + // prototype would invoke whatever getter user code installed there. + TypedArrayPrototypeGetLength: uncurryThis( + Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(Uint8Array.prototype), "length").get), + ArrayBufferPrototypeGetByteLength: uncurryThis( + Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength").get), + DataViewPrototypeGetByteLength: uncurryThis( + Object.getOwnPropertyDescriptor(DataView.prototype, "byteLength").get), }; Object.setPrototypeOf(intrinsics, null); diff --git a/NativeScript/runtime/js/smart-stringify.js b/NativeScript/runtime/js/smart-stringify.js deleted file mode 100644 index 5824f9d1..00000000 --- a/NativeScript/runtime/js/smart-stringify.js +++ /dev/null @@ -1,19 +0,0 @@ -const { ArrayPrototypeIndexOf, ArrayPrototypePush, JSONStringify } = primordials; - -function smartStringify(object) { - const seen = []; - var replacer = function (key, value) { - if (value != null && typeof value == "object") { - if (ArrayPrototypeIndexOf(seen, value) >= 0) { - if (key) { - return "[Circular]"; - } - return; - } - ArrayPrototypePush(seen, value); - } - return value; - }; - return JSONStringify(object, replacer, 2); -} -module.exports = smartStringify; diff --git a/TestRunner/app/tests/InspectTests.js b/TestRunner/app/tests/InspectTests.js new file mode 100644 index 00000000..50f4ff27 --- /dev/null +++ b/TestRunner/app/tests/InspectTests.js @@ -0,0 +1,121 @@ +describe("inspect", function () { + it("formats primitives and plain objects", function () { + expect(__inspect(42)).toBe("42"); + expect(__inspect("hi")).toBe('"hi"'); + expect(__inspect({ a: 1, b: "x" })).toBe('{ a: 1, b: "x" }'); + expect(__inspect([1, [2, 3]])).toBe("[ 1, [ 2, 3 ] ]"); + }); + + it("limits depth", function () { + expect(__inspect({ a: { b: { c: { d: 1 } } } })).toBe("{ a: { b: { c: [Object] } } }"); + expect(__inspect({ a: { b: { c: { d: 1 } } } }, { depth: 3 })).toBe("{ a: { b: { c: { d: 1 } } } }"); + }); + + it("reports true cycles and only true cycles", function () { + var cyc = {}; + cyc.self = cyc; + expect(__inspect(cyc)).toBe("{ self: [Circular] }"); + + var shared = { x: 1 }; + expect(__inspect({ a: shared, b: shared })).toBe("{ a: { x: 1 }, b: { x: 1 } }"); + }); + + it("caps arrays and total output", function () { + var big = []; + for (var i = 0; i < 250; i++) { + big[i] = i; + } + expect(__inspect(big).indexOf("... 150 more items")).toBeGreaterThan(-1); + + var huge = {}; + for (var k = 0; k < 100000; k++) { + huge["key" + k] = k; + } + var start = Date.now(); + var out = __inspect(huge); + var elapsed = Date.now() - start; + expect(out.length).toBeLessThan(20000); + // The old JSON path would serialize all 100k keys; the budgeted + // formatter must not take anywhere near a second. + expect(elapsed).toBeLessThan(1000); + }); + + it("never invokes getters", function () { + var invoked = false; + var obj = {}; + Object.defineProperty(obj, "x", { + enumerable: true, + get: function () { + invoked = true; + return 1; + } + }); + expect(__inspect(obj)).toBe("{ x: [Getter] }"); + expect(invoked).toBe(false); + }); + + it("formats collections, dates, regexes, errors and functions", function () { + expect(__inspect(new Map([["k", 1]]))).toBe('Map(1) { "k" => 1 }'); + expect(__inspect(new Set([1, 2]))).toBe("Set(2) { 1, 2 }"); + expect(__inspect(new Date(0))).toBe("1970-01-01T00:00:00.000Z"); + expect(__inspect(/ab+c/gi)).toBe("/ab+c/gi"); + expect(__inspect(function foo() {})).toBe("[Function: foo]"); + + var errOut = __inspect(new Error("boom")); + expect(errOut.indexOf("Error: boom")).toBe(0); + }); + + it("identifies native objects without walking them", function () { + var str = NSString.stringWithString("abc"); + var out = __inspect(str); + // Concrete class is an NSString subclass; only the bracket form and + // the NS prefix are stable. + expect(out.indexOf("[")).toBe(0); + expect(out.indexOf("NS")).toBeGreaterThan(-1); + }); + + it("console.log of a huge cyclic object completes quickly", function () { + var huge = { name: "root" }; + var cursor = huge; + for (var i = 0; i < 5000; i++) { + cursor = cursor.child = { i: i, parent: huge }; + } + huge.self = huge; + var start = Date.now(); + console.log(huge); + expect(Date.now() - start).toBeLessThan(1000); + }); + + it("honors custom toString overrides (NativeScript core convention)", function () { + function ViewLike() { this.id = 42; } + ViewLike.prototype.toString = function () { return "Button(42)"; }; + expect(__inspect(new ViewLike())).toBe("Button(42)"); + expect(__inspect({ v: new ViewLike() })).toBe("{ v: Button(42) }"); + expect(__inspect({ toString: function () { return "custom!"; } })).toBe("custom!"); + // A broken override degrades to structural rendering instead of hiding data. + var broken = { a: 1, toString: function () { throw new Error("x"); } }; + expect(__inspect(broken).indexOf("a: 1")).toBeGreaterThan(-1); + }); + + it("formats under tampered prototypes", function () { + var savedSlice = Array.prototype.slice; + var savedIndexOf = Array.prototype.indexOf; + var savedKeys = Object.keys; + var savedStringify = JSON.stringify; + var boom = function () { throw new Error("tampered"); }; + var out; + try { + Array.prototype.slice = boom; + Array.prototype.indexOf = boom; + Object.keys = boom; + JSON.stringify = boom; + out = __inspect({ a: [1, 2], m: new Map([[1, 2]]) }); + } finally { + Array.prototype.slice = savedSlice; + Array.prototype.indexOf = savedIndexOf; + Object.keys = savedKeys; + JSON.stringify = savedStringify; + } + expect(out).toBe("{ a: [ 1, 2 ], m: Map(1) { 1 => 2 } }"); + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 7832d422..5ad49d89 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -162,6 +162,7 @@ require("./EscapeExceptionTests"); // Runtime builtins keep working when app code replaces the intrinsics they use require("./PrimordialsTests"); +require("./InspectTests"); // Tests common for all runtimes (git submodule of NativeScript/common-runtime-tests-app). require("../shared/index").runAllTests(); diff --git a/eslint.config.mjs b/eslint.config.mjs index 1b5dc930..b9715242 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,12 +12,17 @@ import globals from 'globals'; // no-restricted-properties on the receiver, so uncurried use of those stays a // review rule. const capturedStatics = [ + ['Array', 'isArray', 'ArrayIsArray'], + ['ArrayBuffer', 'isView', 'ArrayBufferIsView'], ['JSON', 'stringify', 'JSONStringify'], ['Object', 'assign', 'ObjectAssign'], ['Object', 'create', 'ObjectCreate'], ['Object', 'defineProperty', 'ObjectDefineProperty'], ['Object', 'freeze', 'ObjectFreeze'], ['Object', 'getOwnPropertyDescriptor', 'ObjectGetOwnPropertyDescriptor'], + ['Object', 'getOwnPropertySymbols', 'ObjectGetOwnPropertySymbols'], + ['Object', 'getPrototypeOf', 'ObjectGetPrototypeOf'], + ['Object', 'is', 'ObjectIs'], ['Object', 'keys', 'ObjectKeys'], ['Object', 'setPrototypeOf', 'ObjectSetPrototypeOf'], ['Reflect', 'construct', 'ReflectConstruct'], @@ -33,7 +38,7 @@ const capturedStatics = [ // fills in after init). Array.from has no primordial: copying `arguments` goes // through an index loop instead, because Array.from depends on the tamperable // array iterator protocol. -const restrictedGlobals = ['Error', 'Map', 'Proxy', 'String', 'TypeError'].map((name) => ({ +const restrictedGlobals = ['Error', 'Map', 'Proxy', 'Set', 'String', 'TypeError'].map((name) => ({ name, message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`, })); diff --git a/tools/js2c-inputs.xcfilelist b/tools/js2c-inputs.xcfilelist index 617605da..5a6c9072 100644 --- a/tools/js2c-inputs.xcfilelist +++ b/tools/js2c-inputs.xcfilelist @@ -5,8 +5,8 @@ $(SRCROOT)/NativeScript/runtime/js/error-events.js $(SRCROOT)/NativeScript/runtime/js/events.js $(SRCROOT)/NativeScript/runtime/js/inline-functions.js $(SRCROOT)/NativeScript/runtime/js/primordials.js +$(SRCROOT)/NativeScript/runtime/js/inspect.js $(SRCROOT)/NativeScript/runtime/js/promise-proxy.js $(SRCROOT)/NativeScript/runtime/js/require-factory.js -$(SRCROOT)/NativeScript/runtime/js/smart-stringify.js $(SRCROOT)/NativeScript/runtime/js/ts-helpers.js $(SRCROOT)/NativeScript/runtime/js/weak-ref.js diff --git a/tools/js2c.mjs b/tools/js2c.mjs index ef8c97c0..fbb0dde7 100644 --- a/tools/js2c.mjs +++ b/tools/js2c.mjs @@ -130,14 +130,15 @@ function toByteArray(name, text) { for (let i = 0; i < bytes.length; i += 16) { lines.push(' ' + [...bytes.subarray(i, i + 16)].map((b) => `0x${b.toString(16).padStart(2, '0')},`).join(' ')); } - return `static const char ${name}[] = {\n${lines.join('\n')}\n 0x00};\n`; + // unsigned char: UTF-8 bytes >= 0x80 are narrowing errors in a char array. + return `static const unsigned char ${name}[] = {\n${lines.join('\n')}\n 0x00};\n`; } const arrays = builtins .map((b) => `// ${b.stem}.js\n${toByteArray(`kSource${b.id.slice(1)}`, b.source)}`) .join('\n'); const entries = builtins - .map((b) => ` {"${b.origin}", kSource${b.id.slice(1)}, sizeof(kSource${b.id.slice(1)}) - 1},`) + .map((b) => ` {"${b.origin}", reinterpret_cast(kSource${b.id.slice(1)}), sizeof(kSource${b.id.slice(1)}) - 1},`) .join('\n'); const impl = `// generated by tools/js2c.mjs — do not edit #include "RuntimeBuiltins.h"