Skip to content

Commit 98d9ad5

Browse files
committed
feat: support conditional esm, .mjs, consumption
1 parent a2a35d4 commit 98d9ad5

File tree

5 files changed

+307
-39
lines changed

5 files changed

+307
-39
lines changed

NativeScript/runtime/ModuleInternal.h

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ class ModuleInternal {
1111
ModuleInternal(v8::Local<v8::Context> context);
1212
bool RunModule(v8::Isolate* isolate, std::string path);
1313
void RunScript(v8::Isolate* isolate, std::string script);
14+
static v8::Local<v8::Value> LoadScript(v8::Isolate* isolate,
15+
const std::string& path);
1416

1517
private:
1618
static void RequireCallback(const v8::FunctionCallbackInfo<v8::Value>& info);
@@ -19,10 +21,15 @@ class ModuleInternal {
1921
v8::Local<v8::Object> LoadImpl(v8::Isolate* isolate,
2022
const std::string& moduleName,
2123
const std::string& baseDir, bool& isData);
22-
v8::Local<v8::Script> LoadScript(v8::Isolate* isolate,
23-
const std::string& path);
24-
v8::Local<v8::String> WrapModuleContent(v8::Isolate* isolate,
25-
const std::string& path);
24+
// Compile (and cache) a classic script; returns the compiled Script handle.
25+
static v8::Local<v8::Script> LoadClassicScript(v8::Isolate* isolate,
26+
const std::string& path);
27+
28+
// Compile/link/evaluate an ES module; returns its namespace object.
29+
static v8::Local<v8::Value> LoadESModule(v8::Isolate* isolate,
30+
const std::string& path);
31+
static v8::Local<v8::String> WrapModuleContent(v8::Isolate* isolate,
32+
const std::string& path);
2633
v8::Local<v8::Object> LoadModule(v8::Isolate* isolate,
2734
const std::string& modulePath,
2835
const std::string& cacheKey);
@@ -32,10 +39,13 @@ class ModuleInternal {
3239
const std::string& moduleName);
3340
std::string ResolvePathFromPackageJson(const std::string& packageJson,
3441
bool& error);
35-
v8::ScriptCompiler::CachedData* LoadScriptCache(const std::string& path);
36-
void SaveScriptCache(const v8::Local<v8::Script> script,
37-
const std::string& path);
38-
std::string GetCacheFileName(const std::string& path);
42+
static v8::ScriptCompiler::CachedData* LoadScriptCache(
43+
const std::string& path);
44+
static void SaveScriptCache(const v8::Local<v8::Script> script,
45+
const std::string& path);
46+
static void SaveScriptCache(const v8::ScriptCompiler::CachedData* cache,
47+
const std::string& path);
48+
static std::string GetCacheFileName(const std::string& path);
3949
v8::MaybeLocal<v8::Value> RunScriptString(v8::Isolate* isolate,
4050
v8::Local<v8::Context> context,
4151
const std::string script);

NativeScript/runtime/ModuleInternal.mm

Lines changed: 168 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <string>
77
#include "Caches.h"
88
#include "Helpers.h"
9+
#include "ModuleInternalCallbacks.h" // for ResolveModuleCallback
910
#include "NativeScriptException.h"
1011
#include "RuntimeConfig.h"
1112

@@ -115,7 +116,7 @@
115116

116117
const char* path1 = [fullPath fileSystemRepresentation];
117118
const char* path2 =
118-
[[fullPath stringByAppendingPathExtension:@"js"] fileSystemRepresentation];
119+
[[fullPath stringByAppendingPathExtension:@"mjs"] fileSystemRepresentation];
119120

120121
if (!tns::Exists(path1) && !tns::Exists(path2)) {
121122
fullPath = [tnsModulesPath stringByAppendingPathComponent:@"tns-core-modules"];
@@ -183,7 +184,7 @@
183184
return it2->second->Get(isolate);
184185
}
185186

186-
if ([extension isEqualToString:@"js"]) {
187+
if ([extension isEqualToString:@"mjs"]) {
187188
moduleObj = this->LoadModule(isolate, path, cacheKey);
188189
} else if ([extension isEqualToString:@"json"]) {
189190
moduleObj = this->LoadData(isolate, path);
@@ -217,12 +218,17 @@
217218
std::make_shared<Persistent<Object>>(isolate, moduleObj);
218219
TempModule tempModule(this, modulePath, cacheKey, poModuleObj);
219220

220-
Local<Script> script = LoadScript(isolate, modulePath);
221+
Local<Value> scriptValue = LoadScript(isolate, modulePath);
222+
223+
if (!scriptValue->IsFunction()) {
224+
throw NativeScriptException(isolate,
225+
"Expected module factory to be a function for " + modulePath);
226+
}
227+
v8::Local<v8::Function> moduleFunc = scriptValue.As<v8::Function>();
221228

222-
Local<v8::Function> moduleFunc;
223229
{
224230
TryCatch tc(isolate);
225-
moduleFunc = script->Run(context).ToLocalChecked().As<v8::Function>();
231+
// moduleFunc = script->Run(context).ToLocalChecked().As<v8::Function>();
226232
if (tc.HasCaught()) {
227233
throw NativeScriptException(isolate, tc, "Error running script " + modulePath);
228234
}
@@ -282,35 +288,127 @@
282288
return json;
283289
}
284290

285-
Local<Script> ModuleInternal::LoadScript(Isolate* isolate, const std::string& path) {
286-
Local<Context> context = isolate->GetCurrentContext();
287-
std::string baseOrigin = tns::ReplaceAll(path, RuntimeConfig.BaseDir, "");
288-
std::string fullRequiredModulePathWithSchema = "file://" + baseOrigin;
289-
ScriptOrigin origin(isolate, tns::ToV8String(isolate, fullRequiredModulePathWithSchema));
290-
Local<v8::String> scriptText = WrapModuleContent(isolate, path);
291-
ScriptCompiler::CachedData* cacheData = LoadScriptCache(path);
292-
ScriptCompiler::Source source(scriptText, origin, cacheData);
293-
294-
ScriptCompiler::CompileOptions options = ScriptCompiler::kNoCompileOptions;
295-
296-
if (cacheData != nullptr) {
297-
options = ScriptCompiler::kConsumeCodeCache;
291+
Local<Value> ModuleInternal::LoadScript(Isolate* isolate, const std::string& path) {
292+
// Simple dispatch on extension:
293+
if (path.size() >= 4 && path.compare(path.size() - 4, 4, ".mjs") == 0) {
294+
return ModuleInternal::LoadESModule(isolate, path);
295+
} else {
296+
Local<Script> script = ModuleInternal::LoadClassicScript(isolate, path);
297+
// run it and return the value
298+
return script->Run(isolate->GetCurrentContext()).ToLocalChecked();
298299
}
300+
}
301+
302+
Local<Script> ModuleInternal::LoadClassicScript(Isolate* isolate, const std::string& path) {
303+
auto context = isolate->GetCurrentContext();
304+
// build URL
305+
std::string base = ReplaceAll(path, RuntimeConfig.BaseDir, "");
306+
std::string url = "file://" + base;
307+
308+
// wrap & cache lookup
309+
Local<v8::String> sourceText = ModuleInternal::WrapModuleContent(isolate, path);
310+
auto* cacheData = ModuleInternal::LoadScriptCache(path);
311+
312+
// note: is_module=false here
313+
ScriptOrigin origin(
314+
isolate,
315+
v8::String::NewFromUtf8(isolate, url.c_str(), NewStringType::kNormal).ToLocalChecked(),
316+
0, // line offset
317+
0, // column offset
318+
false, // shared_cross_origin
319+
-1, // script_id
320+
Local<Value>(),
321+
false, // is_opaque
322+
false, // is_wasm
323+
false // is_module
324+
);
325+
ScriptCompiler::Source source(sourceText, origin, cacheData);
326+
327+
auto opts = cacheData ? ScriptCompiler::kConsumeCodeCache : ScriptCompiler::kNoCompileOptions;
299328

300-
Local<Script> script;
301329
TryCatch tc(isolate);
302-
bool success = ScriptCompiler::Compile(context, &source, options).ToLocal(&script);
303-
if (!success || tc.HasCaught()) {
304-
throw NativeScriptException(isolate, tc, "Cannot compile " + path);
330+
Local<Script> script;
331+
if (!ScriptCompiler::Compile(context, &source, opts).ToLocal(&script) || tc.HasCaught()) {
332+
throw NativeScriptException(isolate, tc, "Cannot compile script " + path);
305333
}
306334

307335
if (cacheData == nullptr) {
308-
SaveScriptCache(script, path);
336+
ModuleInternal::SaveScriptCache(script, path);
309337
}
310338

311339
return script;
312340
}
313341

342+
Local<Value> ModuleInternal::LoadESModule(Isolate* isolate, const std::string& path) {
343+
auto context = isolate->GetCurrentContext();
344+
345+
// 1) Prepare URL & source
346+
std::string base = ReplaceAll(path, RuntimeConfig.BaseDir, "");
347+
std::string url = "file://" + base;
348+
v8::Local<v8::String> sourceText = ModuleInternal::WrapModuleContent(isolate, path);
349+
auto* cacheData = ModuleInternal::LoadScriptCache(path);
350+
351+
ScriptOrigin origin(
352+
isolate,
353+
v8::String::NewFromUtf8(isolate, url.c_str(), NewStringType::kNormal).ToLocalChecked(), 0, 0,
354+
false, -1, Local<Value>(), false, false,
355+
true // ← is_module
356+
);
357+
ScriptCompiler::Source source(sourceText, origin, cacheData);
358+
359+
// 2) Compile with its own TryCatch
360+
Local<Module> module;
361+
{
362+
TryCatch tcCompile(isolate);
363+
MaybeLocal<Module> maybeMod = ScriptCompiler::CompileModule(
364+
isolate, &source,
365+
cacheData ? ScriptCompiler::kConsumeCodeCache : ScriptCompiler::kNoCompileOptions);
366+
367+
if (!maybeMod.ToLocal(&module)) {
368+
// V8 threw a syntax error or similar
369+
throw NativeScriptException(isolate, tcCompile, "Cannot compile ES module " + path);
370+
}
371+
}
372+
373+
// 3) Register for resolution callback
374+
extern std::unordered_map<std::string, Global<Module>> g_moduleRegistry;
375+
g_moduleRegistry[path].Reset(isolate, module);
376+
377+
// 4) Save cache if first time
378+
if (cacheData == nullptr) {
379+
Local<UnboundModuleScript> unbound = module->GetUnboundModuleScript();
380+
auto* generatedCache = ScriptCompiler::CreateCodeCache(unbound);
381+
ModuleInternal::SaveScriptCache(generatedCache, path);
382+
}
383+
384+
// 5) Instantiate (link) with its own TryCatch
385+
{
386+
TryCatch tcLink(isolate);
387+
bool linked = module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false);
388+
389+
if (!linked) {
390+
if (tcLink.HasCaught()) {
391+
throw NativeScriptException(isolate, tcLink, "Cannot instantiate module " + path);
392+
} else {
393+
// V8 gave no exception object—throw plain text
394+
throw NativeScriptException(isolate, "Cannot instantiate module " + path);
395+
}
396+
}
397+
}
398+
399+
// 6) Evaluate with its own TryCatch
400+
Local<Value> result;
401+
{
402+
TryCatch tcEval(isolate);
403+
if (!module->Evaluate(context).ToLocal(&result)) {
404+
throw NativeScriptException(isolate, tcEval, "Cannot evaluate module " + path);
405+
}
406+
}
407+
408+
// 7) Return the namespace
409+
return module->GetModuleNamespace();
410+
}
411+
314412
MaybeLocal<Value> ModuleInternal::RunScriptString(Isolate* isolate, Local<Context> context,
315413
const std::string scriptString) {
316414
ScriptCompiler::CompileOptions options = ScriptCompiler::kNoCompileOptions;
@@ -332,7 +430,20 @@
332430
this->RunScriptString(isolate, context, script);
333431
}
334432

335-
Local<v8::String> ModuleInternal::WrapModuleContent(Isolate* isolate, const std::string& path) {
433+
v8::Local<v8::String> ModuleInternal::WrapModuleContent(v8::Isolate* isolate,
434+
const std::string& path) {
435+
// For classical scripts we wrap the source into the CommonJS factory function
436+
// but for ES modules (".mjs") we must leave the source intact so that the
437+
// V8 parser can recognise the "export"/"import" syntax. Wrapping an ES module
438+
// in a function expression would turn those top-level keywords into syntax
439+
// errors (e.g. `export *` → "Unexpected token '*'").
440+
441+
if (path.size() >= 4 && path.compare(path.size() - 4, 4, ".mjs") == 0) {
442+
// Read raw text without wrapping.
443+
std::string sourceText = tns::ReadText(path);
444+
return tns::ToV8String(isolate, sourceText);
445+
}
446+
336447
return tns::ReadModule(isolate, path);
337448
}
338449

@@ -348,15 +459,15 @@
348459
BOOL exists = [fileManager fileExistsAtPath:fullPath isDirectory:&isDirectory];
349460

350461
if (exists == YES && isDirectory == YES) {
351-
NSString* jsFile = [fullPath stringByAppendingPathExtension:@"js"];
462+
NSString* jsFile = [fullPath stringByAppendingPathExtension:@"mjs"];
352463
BOOL isDir;
353464
if ([fileManager fileExistsAtPath:jsFile isDirectory:&isDir] && isDir == NO) {
354465
return [jsFile UTF8String];
355466
}
356467
}
357468

358469
if (exists == NO) {
359-
fullPath = [fullPath stringByAppendingPathExtension:@"js"];
470+
fullPath = [fullPath stringByAppendingPathExtension:@"mjs"];
360471
exists = [fileManager fileExistsAtPath:fullPath isDirectory:&isDirectory];
361472
}
362473

@@ -387,9 +498,9 @@ throw NativeScriptException("Unable to locate main entry in " +
387498
}
388499

389500
if (exists == NO) {
390-
fullPath = [fullPath stringByAppendingPathExtension:@"js"];
501+
fullPath = [fullPath stringByAppendingPathExtension:@"mjs"];
391502
} else {
392-
fullPath = [fullPath stringByAppendingPathComponent:@"index.js"];
503+
fullPath = [fullPath stringByAppendingPathComponent:@"index.mjs"];
393504
}
394505

395506
exists = [fileManager fileExistsAtPath:fullPath isDirectory:&isDirectory];
@@ -448,7 +559,7 @@ throw NativeScriptException("Unable to locate main entry in " +
448559
}
449560

450561
long length = 0;
451-
std::string cachePath = GetCacheFileName(path + ".cache");
562+
std::string cachePath = ModuleInternal::GetCacheFileName(path + ".cache");
452563

453564
struct stat result;
454565
if (stat(cachePath.c_str(), &result) == 0) {
@@ -474,6 +585,33 @@ throw NativeScriptException("Unable to locate main entry in " +
474585
isNew ? ScriptCompiler::CachedData::BufferOwned : ScriptCompiler::CachedData::BufferNotOwned);
475586
}
476587

588+
void ModuleInternal::SaveScriptCache(const ScriptCompiler::CachedData* cache,
589+
const std::string& path) {
590+
std::string cachePath = ModuleInternal::GetCacheFileName(path + ".cache");
591+
592+
// std::ofstream ofs(cachePath, std::ios::binary);
593+
// if (!ofs) return; // or throw
594+
595+
// ofs.write(reinterpret_cast<const char*>(cache->data),
596+
// cache->length);
597+
// ofs.close();
598+
599+
int length = cache->length;
600+
tns::WriteBinary(cachePath, cache->data, length);
601+
delete cache;
602+
603+
// make sure cache and js file have the same modification date
604+
struct stat result;
605+
struct utimbuf new_times;
606+
new_times.actime = time(nullptr);
607+
new_times.modtime = time(nullptr);
608+
if (stat(path.c_str(), &result) == 0) {
609+
auto jsLastModifiedTime = result.st_mtime;
610+
new_times.modtime = jsLastModifiedTime;
611+
}
612+
utime(cachePath.c_str(), &new_times);
613+
}
614+
477615
void ModuleInternal::SaveScriptCache(const Local<Script> script, const std::string& path) {
478616
if (RuntimeConfig.IsDebug) {
479617
return;
@@ -484,7 +622,7 @@ throw NativeScriptException("Unable to locate main entry in " +
484622
ScriptCompiler::CachedData* cachedData = ScriptCompiler::CreateCodeCache(unboundScript);
485623

486624
int length = cachedData->length;
487-
std::string cachePath = GetCacheFileName(path + ".cache");
625+
std::string cachePath = ModuleInternal::GetCacheFileName(path + ".cache");
488626
tns::WriteBinary(cachePath, cachedData->data, length);
489627
delete cachedData;
490628

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// ModuleInternalCallbacks.h
2+
#pragma once
3+
#include <v8.h>
4+
5+
#include <string>
6+
#include <unordered_map>
7+
8+
namespace tns {
9+
10+
// Export our registry so both LoadESModule and the callback see the same data:
11+
extern std::unordered_map<std::string, v8::Global<v8::Module>> g_moduleRegistry;
12+
13+
// Resolve callback signature (with import‑assertions slot)
14+
v8::MaybeLocal<v8::Module> ResolveModuleCallback(
15+
v8::Local<v8::Context> context, v8::Local<v8::String> specifier,
16+
v8::Local<v8::FixedArray> import_assertions,
17+
v8::Local<v8::Module> referrer);
18+
19+
} // namespace tns

0 commit comments

Comments
 (0)