Skip to content

Commit 91ce499

Browse files
authored
feat(inspector): serve source maps to DevTools via Network.loadNetworkResource (#385)
Chrome DevTools no longer fetches external source maps itself when debugging remote targets: it issues Network.loadNetworkResource to the target and reads the result back through IO.read/IO.close. None of these embedder-side CDP domains are implemented by V8's inspector, so external source maps failed and apps had to fall back to bloated inline-source-map builds. - Handle Network.loadNetworkResource natively: resolve the URL back to a file under RuntimeConfig.BaseDir and reply with a stream handle (success:false + net::ERR_FILE_NOT_FOUND when missing). - Implement IO.read (1MB base64 chunks; eof only on a final empty read, since the frontend discards data accompanying eof) and IO.close. - Reply with a JSON-RPC error for unsupported schemes (e.g. https) so DevTools keeps its existing fallback of fetching from the host. - Rewrite sourceMapURL in outgoing Debugger.scriptParsed / Debugger.scriptFailedToParse events from relative/file:// URLs to a custom nsruntime:// scheme. DevTools hard-excludes file:, data: and devtools: URLs from loading through the target, so without the rewrite it would never send Network.loadNetworkResource and instead try (and fail) to read device files from the host machine. data: and http(s) URLs are left untouched, keeping inline source maps working. - Allow opting out via nativescript.config.ts: ios.disableSourceMapURLRewrite (or the same key at the top level). Page.enable/Page.getResourceTree are not required: the frontend falls back to a null frameId, which is optional in loadNetworkResource. Refs: nodejs/node#58077
1 parent debf7f8 commit 91ce499

2 files changed

Lines changed: 238 additions & 3 deletions

File tree

NativeScript/inspector/JsV8InspectorClient.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ class JsV8InspectorClient : V8InspectorClient, V8Inspector::Channel {
6161

6262
std::unique_ptr<tns::inspector::TracingAgentImpl> tracing_agent_;
6363

64+
// Streams backing Network.loadNetworkResource responses, read by the
65+
// frontend through IO.read/IO.close (how Chrome DevTools fetches source
66+
// maps from the target). Only touched from dispatchMessage (main thread).
67+
struct ResourceStream {
68+
std::string data;
69+
size_t offset = 0;
70+
};
71+
std::map<std::string, ResourceStream> resourceStreams_;
72+
int lastStreamId_ = 0;
73+
6474
// Override of V8InspectorClient
6575
v8::Local<v8::Context> ensureDefaultContextInGroup(
6676
int contextGroupId) override;
@@ -79,6 +89,12 @@ class JsV8InspectorClient : V8InspectorClient, V8Inspector::Channel {
7989
static void inspectorTimestampCallback(
8090
const v8::FunctionCallbackInfo<v8::Value>& args);
8191

92+
// Source map delivery to Chrome DevTools (Network.loadNetworkResource + IO
93+
// domain). V8's inspector doesn't implement these embedder domains.
94+
void HandleLoadNetworkResource(int msgId, const std::string& url);
95+
void HandleIORead(int msgId, const std::string& handle, int size);
96+
void HandleIOClose(int msgId, const std::string& handle);
97+
8298
// {N} specific helpers
8399
bool CallDomainHandlerFunction(v8::Local<v8::Context> context,
84100
v8::Local<v8::Function> domainMethodFunc,

NativeScript/inspector/JsV8InspectorClient.mm

Lines changed: 222 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#include <Foundation/Foundation.h>
22
#include <notify.h>
3+
#include <algorithm>
34
#include <chrono>
45

56
#include "src/inspector/v8-console-message.h"
@@ -31,8 +32,89 @@
3132
// dodges the libc++ deprecation that prompted the inspector's
3233
// `UChar = uint16_t -> char16_t` switch.
3334
StringView Make8BitStringView(const std::string& value) {
34-
return StringView(reinterpret_cast<const uint8_t*>(value.data()),
35-
value.size());
35+
return StringView(reinterpret_cast<const uint8_t*>(value.data()), value.size());
36+
}
37+
38+
// Scheme advertised to the frontend for source maps the runtime can serve.
39+
// Chrome DevTools never loads `file:` (or `data:`/`devtools:`) resources
40+
// through the target -- PageResourceLoader routes those to the frontend host
41+
// machine, which cannot see files on the device. Any other scheme is fetched
42+
// with Network.loadNetworkResource, which we answer from disk.
43+
constexpr const char* kSourceMapScheme = "nsruntime://";
44+
45+
// Opt-out via nativescript.config.ts (serialized into the bundled
46+
// package.json): `ios: { disableSourceMapURLRewrite: true }`, or the same key
47+
// at the top level.
48+
bool ShouldRewriteSourceMapURLs() {
49+
static bool disabled = []() {
50+
id ios = tns::Runtime::GetAppConfigValue("ios");
51+
id value = [ios isKindOfClass:[NSDictionary class]] ? ios[@"disableSourceMapURLRewrite"] : nil;
52+
if (value == nil) {
53+
value = tns::Runtime::GetAppConfigValue("disableSourceMapURLRewrite");
54+
}
55+
return value != nil && [value boolValue];
56+
}();
57+
return !disabled;
58+
}
59+
60+
// Rewrites the sourceMapURL of outgoing Debugger.scriptParsed /
61+
// Debugger.scriptFailedToParse events from a file url (or a url relative to
62+
// the script's file url) to an absolute nsruntime:// url, so DevTools
63+
// requests the map through the target instead of the frontend host.
64+
std::string MaybeRewriteSourceMapURL(const std::string& message) {
65+
if (!ShouldRewriteSourceMapURLs()) {
66+
return message;
67+
}
68+
69+
if (message.find("\"Debugger.scriptParsed\"") == std::string::npos &&
70+
message.find("\"Debugger.scriptFailedToParse\"") == std::string::npos) {
71+
return message;
72+
}
73+
74+
auto parsed = json::parse(message, nullptr, false);
75+
if (parsed.is_discarded() || !parsed.contains("params")) {
76+
return message;
77+
}
78+
79+
auto& params = parsed["params"];
80+
std::string sourceMapURL = params.value("sourceMapURL", "");
81+
if (sourceMapURL.empty() || sourceMapURL.rfind("data:", 0) == 0 ||
82+
sourceMapURL.rfind("http:", 0) == 0 || sourceMapURL.rfind("https:", 0) == 0 ||
83+
sourceMapURL.rfind(kSourceMapScheme, 0) == 0) {
84+
return message;
85+
}
86+
87+
std::string path;
88+
if (sourceMapURL.rfind("file://", 0) == 0) {
89+
path = sourceMapURL.substr(strlen("file://"));
90+
} else if (sourceMapURL[0] == '/') {
91+
path = sourceMapURL;
92+
} else {
93+
// Relative to the script url, e.g. "bundle.js.map".
94+
std::string scriptUrl = params.value("url", "");
95+
if (scriptUrl.rfind("file://", 0) != 0) {
96+
return message;
97+
}
98+
@autoreleasepool {
99+
NSString* scriptPath =
100+
[NSString stringWithUTF8String:scriptUrl.substr(strlen("file://")).c_str()];
101+
NSString* mapPath = [NSString stringWithUTF8String:sourceMapURL.c_str()];
102+
if (scriptPath != nil && mapPath != nil) {
103+
NSString* resolved = [[[scriptPath stringByDeletingLastPathComponent]
104+
stringByAppendingPathComponent:mapPath] stringByStandardizingPath];
105+
if (resolved != nil) {
106+
path = [resolved UTF8String];
107+
}
108+
}
109+
}
110+
}
111+
112+
if (path.empty()) {
113+
return message;
114+
}
115+
116+
params["sourceMapURL"] = kSourceMapScheme + path;
117+
return parsed.dump();
36118
}
37119
} // namespace
38120

@@ -278,7 +360,7 @@ StringView Make8BitStringView(const std::string& value) {
278360

279361
void JsV8InspectorClient::notify(const std::string& message) {
280362
if (this->sender_) {
281-
this->sender_(message);
363+
this->sender_(MaybeRewriteSourceMapURL(message));
282364
}
283365
}
284366

@@ -340,6 +422,40 @@ StringView Make8BitStringView(const std::string& value) {
340422
return;
341423
}
342424

425+
// Chrome DevTools fetches source maps through the target: it sends
426+
// Network.loadNetworkResource for the resolved sourceMappingURL and reads
427+
// the returned stream with IO.read/IO.close. Neither domain is implemented
428+
// by V8's inspector, so handle them here.
429+
if (method == "Network.loadNetworkResource") {
430+
std::string url;
431+
if (json_message.contains("params") && json_message["params"].contains("url")) {
432+
url = json_message["params"]["url"].get<std::string>();
433+
}
434+
this->HandleLoadNetworkResource(json_message["id"].get<int>(), url);
435+
return;
436+
}
437+
438+
if (method == "IO.read" || method == "IO.close") {
439+
std::string handle;
440+
int size = 0;
441+
if (json_message.contains("params")) {
442+
const auto& params = json_message["params"];
443+
if (params.contains("handle")) {
444+
handle = params["handle"].get<std::string>();
445+
}
446+
if (params.contains("size")) {
447+
size = params["size"].get<int>();
448+
}
449+
}
450+
451+
if (method == "IO.read") {
452+
this->HandleIORead(json_message["id"].get<int>(), handle, size);
453+
} else {
454+
this->HandleIOClose(json_message["id"].get<int>(), handle);
455+
}
456+
return;
457+
}
458+
343459
// parse incoming message as JSON
344460
Local<Value> arg;
345461
success = v8::JSON::Parse(context, tns::ToV8String(isolate, message)).ToLocal(&arg);
@@ -394,6 +510,109 @@ StringView Make8BitStringView(const std::string& value) {
394510
isolate->PerformMicrotaskCheckpoint();
395511
}
396512

513+
void JsV8InspectorClient::HandleLoadNetworkResource(int msgId, const std::string& url) {
514+
std::string path;
515+
if (url.rfind(kSourceMapScheme, 0) == 0) {
516+
path = url.substr(strlen(kSourceMapScheme));
517+
} else if (url.rfind("file://", 0) == 0) {
518+
path = url.substr(strlen("file://"));
519+
} else {
520+
// Reply with a protocol error (not success:false) so DevTools falls back
521+
// to loading the resource from the frontend host, which is the
522+
// pre-existing behavior for http(s) urls.
523+
json error = {{"id", msgId},
524+
{"error", {{"code", -32000}, {"message", "Unsupported URL scheme"}}}};
525+
this->notify(error.dump());
526+
return;
527+
}
528+
529+
std::string content;
530+
bool loaded = false;
531+
532+
if (!path.empty()) {
533+
@autoreleasepool {
534+
NSString* urlPath = [NSString stringWithUTF8String:path.c_str()];
535+
if (urlPath != nil) {
536+
// Script urls are built by stripping RuntimeConfig.BaseDir (see
537+
// ModuleInternal::LoadClassicScript), so map the url path back to
538+
// disk; fall back to the raw path for absolute urls, and to
539+
// percent-decoded variants for urls the frontend encoded.
540+
NSString* basePath = [NSString stringWithUTF8String:RuntimeConfig.BaseDir.c_str()];
541+
NSMutableArray<NSString*>* candidates = [NSMutableArray new];
542+
[candidates addObject:[basePath stringByAppendingPathComponent:urlPath]];
543+
[candidates addObject:urlPath];
544+
NSString* decoded = [urlPath stringByRemovingPercentEncoding];
545+
if (decoded != nil && ![decoded isEqualToString:urlPath]) {
546+
[candidates addObject:[basePath stringByAppendingPathComponent:decoded]];
547+
[candidates addObject:decoded];
548+
}
549+
550+
for (NSString* candidate in candidates) {
551+
NSData* data = [NSData dataWithContentsOfFile:candidate];
552+
if (data != nil) {
553+
content.assign(static_cast<const char*>(data.bytes), data.length);
554+
loaded = true;
555+
break;
556+
}
557+
}
558+
}
559+
}
560+
}
561+
562+
json resource;
563+
if (loaded) {
564+
std::string handle = "ns-network-resource-" + std::to_string(++lastStreamId_);
565+
resourceStreams_[handle] = {std::move(content), 0};
566+
resource = {{"success", true}, {"httpStatusCode", 200}, {"stream", handle}};
567+
} else {
568+
resource = {{"success", false},
569+
{"netError", -6},
570+
{"netErrorName", "net::ERR_FILE_NOT_FOUND"},
571+
{"httpStatusCode", 404}};
572+
}
573+
574+
json response = {{"id", msgId}, {"result", {{"resource", resource}}}};
575+
this->notify(response.dump());
576+
}
577+
578+
void JsV8InspectorClient::HandleIORead(int msgId, const std::string& handle, int size) {
579+
auto it = resourceStreams_.find(handle);
580+
if (it == resourceStreams_.end()) {
581+
json error = {{"id", msgId},
582+
{"error", {{"code", -32602}, {"message", "Invalid stream handle"}}}};
583+
this->notify(error.dump());
584+
return;
585+
}
586+
587+
ResourceStream& stream = it->second;
588+
constexpr size_t kDefaultChunkSize = 1024 * 1024;
589+
size_t chunkSize = size > 0 ? static_cast<size_t>(size) : kDefaultChunkSize;
590+
size_t remaining = stream.data.size() - stream.offset;
591+
chunkSize = std::min(chunkSize, remaining);
592+
593+
json result;
594+
if (chunkSize == 0) {
595+
// DevTools ignores any data sent alongside eof, so only signal it once
596+
// the whole stream has been delivered.
597+
result = {{"data", ""}, {"eof", true}, {"base64Encoded", false}};
598+
} else {
599+
// Base64 keeps arbitrary file bytes intact through the JSON transport.
600+
NSData* chunk = [NSData dataWithBytes:stream.data.data() + stream.offset length:chunkSize];
601+
NSString* encoded = [chunk base64EncodedStringWithOptions:0];
602+
stream.offset += chunkSize;
603+
result = {{"data", [encoded UTF8String]}, {"eof", false}, {"base64Encoded", true}};
604+
}
605+
606+
json response = {{"id", msgId}, {"result", result}};
607+
this->notify(response.dump());
608+
}
609+
610+
void JsV8InspectorClient::HandleIOClose(int msgId, const std::string& handle) {
611+
resourceStreams_.erase(handle);
612+
json response = {{"id", msgId}, {"result", json::object()}};
613+
this->notify(response.dump());
614+
}
615+
397616
Local<Context> JsV8InspectorClient::ensureDefaultContextInGroup(int contextGroupId) {
398617
return context_.Get(isolate_);
399618
}

0 commit comments

Comments
 (0)