@@ -421,6 +421,11 @@ void UpdateModuleFallback(v8::Isolate* isolate, const std::string& canonicalPath
421421static thread_local std::unordered_map<std::string, std::string> g_modulePrimaryImporters;
422422static thread_local std::unordered_set<std::string> g_modulesInFlight;
423423static thread_local std::unordered_set<std::string> g_modulesPendingReset;
424+ // The threshold for detecting circular dependencies during module resolution.
425+ // 256 was chosen as a high enough value to allow deep but legitimate module graphs,
426+ // but low enough to catch runaway recursion or infinite circular imports.
427+ // If a module is re-entered more than this limit, module loading is aborted and
428+ // an error is reported to prevent stack overflow or infinite loops.
424429static constexpr size_t kMaxModuleReentryCount = 256 ;
425430// Waiters: module path -> list of Promise resolvers waiting for completion (instantiated/evaluated or errored)
426431static std::unordered_map<std::string, std::vector<v8::Global<v8::Promise::Resolver>>> g_moduleWaiters;
@@ -680,40 +685,44 @@ static bool IsDocumentsPath(const std::string& path) {
680685
681686 // 1) Turn the specifier literal into a std::string:
682687 v8::String::Utf8Value specUtf8 (isolate, specifier);
683- std::string spec = *specUtf8 ? *specUtf8 : " " ;
684- if (spec .empty ()) {
688+ const std::string rawSpec = *specUtf8 ? *specUtf8 : " " ;
689+ if (rawSpec .empty ()) {
685690 return v8::MaybeLocal<v8::Module>();
686691 }
687692
693+ std::string normalizedSpec = rawSpec;
694+
688695 // Normalize malformed HTTP(S) schemes that sometimes appear as 'http:/host' (single slash)
689696 // due to upstream path joins or standardization. This ensures our HTTP loader fast-path
690697 // is used and avoids filesystem fallback attempts like '/app/http:/host'.
691- if (spec .rfind (" http:/" , 0 ) == 0 && spec .rfind (" http://" , 0 ) != 0 ) {
692- spec .insert (5 , " /" ); // http:/ -> http://
693- } else if (spec .rfind (" https:/" , 0 ) == 0 && spec .rfind (" https://" , 0 ) != 0 ) {
694- spec .insert (6 , " /" ); // https:/ -> https://
698+ if (normalizedSpec .rfind (" http:/" , 0 ) == 0 && normalizedSpec .rfind (" http://" , 0 ) != 0 ) {
699+ normalizedSpec .insert (5 , " /" ); // http:/ -> http://
700+ } else if (normalizedSpec .rfind (" https:/" , 0 ) == 0 && normalizedSpec .rfind (" https://" , 0 ) != 0 ) {
701+ normalizedSpec .insert (6 , " /" ); // https:/ -> https://
695702 }
696703
697704 if (IsScriptLoadingLogEnabled ()) {
698- Log (@" [resolver][spec] %s " , spec .c_str ());
705+ Log (@" [resolver][spec] %s " , normalizedSpec .c_str ());
699706 }
700707
701708 // Normalize '@/' alias to '/src/' for static imports (mirrors client dynamic import normalization)
702- if (spec .rfind (" @/" , 0 ) == 0 ) {
703- std::string orig = spec ;
704- spec = std::string (" /src/" ) + spec .substr (2 );
709+ if (normalizedSpec .rfind (" @/" , 0 ) == 0 ) {
710+ std::string orig = normalizedSpec ;
711+ normalizedSpec = std::string (" /src/" ) + normalizedSpec .substr (2 );
705712 if (IsScriptLoadingLogEnabled ()) {
706- Log (@" [resolver][normalize] %@ -> %@ " , [NSString stringWithUTF8String: orig.c_str ()], [NSString stringWithUTF8String: spec .c_str ()]);
713+ Log (@" [resolver][normalize] %@ -> %@ " , [NSString stringWithUTF8String: orig.c_str ()], [NSString stringWithUTF8String: normalizedSpec .c_str ()]);
707714 }
708715 }
709716 // Guard against a bare '@' spec showing up (invalid); return empty to avoid poisoning registry with '@'
710- if (spec == " @" ) {
717+ if (normalizedSpec == " @" ) {
711718 if (IsScriptLoadingLogEnabled ()) {
712719 Log (@" [resolver][normalize] ignoring invalid '@' static spec" );
713720 }
714721 return v8::MaybeLocal<v8::Module>();
715722 }
716723
724+ const std::string& spec = normalizedSpec; // use normalized spec for the rest of the resolution logic
725+
717726 // ── Early absolute-HTTP fast path ─────────────────────────────
718727 // If the specifier itself is an absolute HTTP(S) URL, resolve it immediately via
719728 // the HTTP dev loader and return before any filesystem candidate logic runs.
@@ -1199,8 +1208,13 @@ static bool IsDocumentsPath(const std::string& path) {
11991208 if (qpos != std::string::npos) baseNoQuery = baseNoQuery.substr (0 , qpos);
12001209 // Strip a terminal .ts/.js when constructing mirror .mjs candidate
12011210 std::string noExt = baseNoQuery;
1202- if (EndsWith (noExt, " .ts" ) || EndsWith (noExt, " .js" )) {
1203- noExt = noExt.substr (0 , noExt.size () - 3 );
1211+ // Handle variable-length extensions: .ts, .js, .tsx, .jsx, .mts, .cts
1212+ const std::vector<std::string> knownExts = {" .ts" , " .js" , " .tsx" , " .jsx" , " .mts" , " .cts" };
1213+ for (const auto & ext : knownExts) {
1214+ if (EndsWith (noExt, ext)) {
1215+ noExt = noExt.substr (0 , noExt.size () - ext.size ());
1216+ break ;
1217+ }
12041218 }
12051219 // Use cached Documents directory (generic dynamic fetch mirror fallback)
12061220 const std::string& docsRootBase = GetDocumentsDirectory ();
@@ -1498,7 +1512,7 @@ static bool IsDocumentsPath(const std::string& path) {
14981512 if (parentKey == primaryImporter) {
14991513 parentAlreadyRecorded = false ; // Owner re-entry is expected during evaluation.
15001514 } else {
1501- // NEW: gating block—only applied for dynamic Documents modules when unfinished.
1515+ // gating block—only applied for dynamic Documents modules when unfinished.
15021516 if (!gatingDisabled && unfinished) {
15031517 g_hmrModuleGatedCount.fetch_add (1 , std::memory_order_relaxed);
15041518 if (IsScriptLoadingLogEnabled ()) {
0 commit comments