Skip to content

Commit e66a428

Browse files
committed
feat: es module dynamic import support
1 parent d25130f commit e66a428

File tree

3 files changed

+193
-2
lines changed

3 files changed

+193
-2
lines changed

test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ v8::MaybeLocal<v8::Module> ResolveModuleCallback(v8::Local<v8::Context> context,
129129

130130
// Debug logging
131131
DEBUG_WRITE("ResolveModuleCallback: Resolving '%s'", spec.c_str());
132+
__android_log_print(ANDROID_LOG_DEBUG, "TNS.ResolveCallback", "Resolving module: '%s'", spec.c_str());
132133

133134
// 2) Find which filepath the referrer was compiled under
134135
std::string referrerPath;
@@ -165,10 +166,33 @@ v8::MaybeLocal<v8::Module> ResolveModuleCallback(v8::Local<v8::Context> context,
165166
} else if (spec.size() > 7 && spec.substr(0, 7) == "file://") {
166167
// Absolute file URL
167168
std::string tail = spec.substr(7); // strip file://
168-
if (tail[0] != '/') {
169+
if (tail.empty() || tail[0] != '/') {
169170
tail = "/" + tail;
170171
}
171-
std::string candidate = appPath + tail;
172+
173+
// Map common virtual roots to the real appPath
174+
const std::string appVirtualRoot = "/app/"; // e.g. file:///app/foo.mjs
175+
const std::string androidAssetAppRoot = "/android_asset/app/"; // e.g. file:///android_asset/app/foo.mjs
176+
177+
std::string candidate;
178+
if (tail.rfind(appVirtualRoot, 0) == 0) {
179+
// Drop the leading "/app/" and prepend real appPath
180+
candidate = appPath + "/" + tail.substr(appVirtualRoot.size());
181+
DEBUG_WRITE("ResolveModuleCallback: file:// to appPath mapping: '%s' -> '%s'", tail.c_str(), candidate.c_str());
182+
} else if (tail.rfind(androidAssetAppRoot, 0) == 0) {
183+
// Replace "/android_asset/app/" with the real appPath
184+
candidate = appPath + "/" + tail.substr(androidAssetAppRoot.size());
185+
DEBUG_WRITE("ResolveModuleCallback: file:// android_asset mapping: '%s' -> '%s'", tail.c_str(), candidate.c_str());
186+
} else if (tail.rfind(appPath, 0) == 0) {
187+
// Already an absolute on-disk path to the app folder
188+
candidate = tail;
189+
DEBUG_WRITE("ResolveModuleCallback: file:// absolute path preserved: '%s'", candidate.c_str());
190+
} else {
191+
// Fallback: treat as absolute on-disk path
192+
candidate = tail;
193+
DEBUG_WRITE("ResolveModuleCallback: file:// generic absolute: '%s'", candidate.c_str());
194+
}
195+
172196
candidateBases.push_back(candidate);
173197
} else if (!spec.empty() && spec[0] == '~') {
174198
// Alias to application root using ~/path
@@ -258,6 +282,85 @@ v8::MaybeLocal<v8::Module> ResolveModuleCallback(v8::Local<v8::Context> context,
258282
"export function pathToFileURL(path) {\n"
259283
" return new URL('file://' + encodeURIComponent(path));\n"
260284
"}\n";
285+
} else if (builtinName == "module") {
286+
// Create a polyfill for node:module with createRequire
287+
polyfillContent = "// Polyfill for node:module\n"
288+
"export function createRequire(filename) {\n"
289+
" // Return the global require function\n"
290+
" // In NativeScript, require is globally available\n"
291+
" if (typeof require === 'function') {\n"
292+
" return require;\n"
293+
" }\n"
294+
" \n"
295+
" // Fallback: create a basic require function\n"
296+
" return function(id) {\n"
297+
" throw new Error('Module ' + id + ' not found. NativeScript require() not available.');\n"
298+
" };\n"
299+
"}\n"
300+
"\n"
301+
"// Export as default as well for compatibility\n"
302+
"export default { createRequire };\n";
303+
} else if (builtinName == "path") {
304+
// Create a polyfill for node:path
305+
polyfillContent = "// Polyfill for node:path\n"
306+
"export const sep = '/';\n"
307+
"export const delimiter = ':';\n"
308+
"\n"
309+
"export function basename(path, ext) {\n"
310+
" const name = path.split('/').pop() || '';\n"
311+
" return ext && name.endsWith(ext) ? name.slice(0, -ext.length) : name;\n"
312+
"}\n"
313+
"\n"
314+
"export function dirname(path) {\n"
315+
" const parts = path.split('/');\n"
316+
" return parts.slice(0, -1).join('/') || '/';\n"
317+
"}\n"
318+
"\n"
319+
"export function extname(path) {\n"
320+
" const name = basename(path);\n"
321+
" const dot = name.lastIndexOf('.');\n"
322+
" return dot > 0 ? name.slice(dot) : '';\n"
323+
"}\n"
324+
"\n"
325+
"export function join(...paths) {\n"
326+
" return paths.filter(Boolean).join('/').replace(/\\/+/g, '/');\n"
327+
"}\n"
328+
"\n"
329+
"export function resolve(...paths) {\n"
330+
" let resolved = '';\n"
331+
" for (let path of paths) {\n"
332+
" if (path.startsWith('/')) {\n"
333+
" resolved = path;\n"
334+
" } else {\n"
335+
" resolved = join(resolved, path);\n"
336+
" }\n"
337+
" }\n"
338+
" return resolved || '/';\n"
339+
"}\n"
340+
"\n"
341+
"export function isAbsolute(path) {\n"
342+
" return path.startsWith('/');\n"
343+
"}\n"
344+
"\n"
345+
"export default { basename, dirname, extname, join, resolve, isAbsolute, sep, delimiter };\n";
346+
} else if (builtinName == "fs") {
347+
// Create a basic polyfill for node:fs
348+
polyfillContent = "// Polyfill for node:fs\n"
349+
"console.warn('Node.js fs module is not supported in NativeScript. Use @nativescript/core File APIs instead.');\n"
350+
"\n"
351+
"export function readFileSync() {\n"
352+
" throw new Error('fs.readFileSync is not supported in NativeScript. Use @nativescript/core File APIs.');\n"
353+
"}\n"
354+
"\n"
355+
"export function writeFileSync() {\n"
356+
" throw new Error('fs.writeFileSync is not supported in NativeScript. Use @nativescript/core File APIs.');\n"
357+
"}\n"
358+
"\n"
359+
"export function existsSync() {\n"
360+
" throw new Error('fs.existsSync is not supported in NativeScript. Use @nativescript/core File APIs.');\n"
361+
"}\n"
362+
"\n"
363+
"export default { readFileSync, writeFileSync, existsSync };\n";
261364
} else {
262365
// Generic polyfill for other Node.js built-in modules
263366
polyfillContent = "// Polyfill for node:" + builtinName + "\n"
@@ -398,3 +501,81 @@ v8::MaybeLocal<v8::Module> ResolveModuleCallback(v8::Local<v8::Context> context,
398501

399502
return v8::MaybeLocal<v8::Module>(it2->second.Get(isolate));
400503
}
504+
505+
// Dynamic import() host callback
506+
v8::MaybeLocal<v8::Promise> ImportModuleDynamicallyCallback(
507+
v8::Local<v8::Context> context, v8::Local<v8::Data> host_defined_options,
508+
v8::Local<v8::Value> resource_name, v8::Local<v8::String> specifier,
509+
v8::Local<v8::FixedArray> import_assertions) {
510+
v8::Isolate* isolate = context->GetIsolate();
511+
512+
// Convert specifier to std::string for logging
513+
v8::String::Utf8Value specUtf8(isolate, specifier);
514+
std::string spec = *specUtf8 ? *specUtf8 : "";
515+
516+
DEBUG_WRITE("ImportModuleDynamicallyCallback: Dynamic import for '%s'", spec.c_str());
517+
__android_log_print(ANDROID_LOG_DEBUG, "TNS.ImportCallback", "Dynamic import: '%s'", spec.c_str());
518+
519+
v8::EscapableHandleScope scope(isolate);
520+
521+
// Create a Promise resolver we'll resolve/reject synchronously for now.
522+
v8::Local<v8::Promise::Resolver> resolver;
523+
if (!v8::Promise::Resolver::New(context).ToLocal(&resolver)) {
524+
// Failed to create resolver, return empty promise
525+
return v8::MaybeLocal<v8::Promise>();
526+
}
527+
528+
// Re-use the static resolver to locate / compile the module.
529+
try {
530+
// Pass empty referrer since this V8 version doesn't expose GetModule() on
531+
// ScriptOrModule. The resolver will fall back to absolute-path heuristics.
532+
v8::Local<v8::Module> refMod;
533+
534+
v8::MaybeLocal<v8::Module> maybeModule =
535+
ResolveModuleCallback(context, specifier, import_assertions, refMod);
536+
537+
v8::Local<v8::Module> module;
538+
if (!maybeModule.ToLocal(&module)) {
539+
// Resolution failed; reject to avoid leaving a pending Promise (white screen)
540+
DEBUG_WRITE("ImportModuleDynamicallyCallback: Resolution failed for '%s'", spec.c_str());
541+
v8::Local<v8::Value> ex = v8::Exception::Error(
542+
ArgConverter::ConvertToV8String(isolate, std::string("Failed to resolve module: ") + spec));
543+
resolver->Reject(context, ex).Check();
544+
return scope.Escape(resolver->GetPromise());
545+
}
546+
547+
// If not yet instantiated/evaluated, do it now
548+
if (module->GetStatus() == v8::Module::kUninstantiated) {
549+
if (!module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) {
550+
DEBUG_WRITE("ImportModuleDynamicallyCallback: Instantiate failed for '%s'", spec.c_str());
551+
resolver
552+
->Reject(context,
553+
v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Failed to instantiate module")))
554+
.Check();
555+
return scope.Escape(resolver->GetPromise());
556+
}
557+
}
558+
559+
if (module->GetStatus() != v8::Module::kEvaluated) {
560+
if (module->Evaluate(context).IsEmpty()) {
561+
DEBUG_WRITE("ImportModuleDynamicallyCallback: Evaluation failed for '%s'", spec.c_str());
562+
v8::Local<v8::Value> ex =
563+
v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Evaluation failed"));
564+
resolver->Reject(context, ex).Check();
565+
return scope.Escape(resolver->GetPromise());
566+
}
567+
}
568+
569+
resolver->Resolve(context, module->GetModuleNamespace()).Check();
570+
DEBUG_WRITE("ImportModuleDynamicallyCallback: Successfully resolved '%s'", spec.c_str());
571+
} catch (NativeScriptException& ex) {
572+
ex.ReThrowToV8();
573+
DEBUG_WRITE("ImportModuleDynamicallyCallback: Native exception for '%s'", spec.c_str());
574+
resolver
575+
->Reject(context, v8::Exception::Error(
576+
ArgConverter::ConvertToV8String(isolate, "Native error during dynamic import")))
577+
.Check();
578+
}
579+
580+
return scope.Escape(resolver->GetPromise());
581+
}

test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ void InitializeImportMetaObject(v8::Local<v8::Context> context,
1414
v8::Local<v8::Module> module,
1515
v8::Local<v8::Object> meta);
1616

17+
// Dynamic import() host callback
18+
v8::MaybeLocal<v8::Promise> ImportModuleDynamicallyCallback(
19+
v8::Local<v8::Context> context, v8::Local<v8::Data> host_defined_options,
20+
v8::Local<v8::Value> resource_name, v8::Local<v8::String> specifier,
21+
v8::Local<v8::FixedArray> import_assertions);
22+
1723
// Helper functions
1824
bool IsFile(const std::string& path);
1925
std::string WithExtension(const std::string& path, const std::string& ext);

test-app/runtime/src/main/cpp/Runtime.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
#include "SimpleProfiler.h"
1515
#include "SimpleAllocator.h"
1616
#include "ModuleInternal.h"
17+
#include "ModuleInternalCallbacks.h"
1718
#include "NativeScriptException.h"
1819
#include "Runtime.h"
1920
#include "ArrayHelper.h"
@@ -508,6 +509,9 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, const string& native
508509
// Set up import.meta callback
509510
isolate->SetHostInitializeImportMetaObjectCallback(InitializeImportMetaObject);
510511

512+
// Enable dynamic import() support
513+
isolate->SetHostImportModuleDynamicallyCallback(ImportModuleDynamicallyCallback);
514+
511515
isolate->AddMessageListener(NativeScriptException::OnUncaughtError);
512516

513517
__android_log_print(ANDROID_LOG_DEBUG, "TNS.Runtime", "V8 version %s", V8::GetVersion());

0 commit comments

Comments
 (0)