Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions test-app/app/src/main/assets/app/esm/relative/query-entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import defaultValue, { relativeValue } from "./dependency.mjs?v=static";

export const viaDefault = defaultValue;
export const viaNamed = relativeValue;

export function loadWithQuery() {
return import("./dependency.mjs?v=dynamic");
}
86 changes: 86 additions & 0 deletions test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ describe("HTTP ESM Loader", function () {
});
});

// A query or fragment on a specifier that names a file is URL syntax
// the filesystem never sees. The same statement in a served module
// keeps it — see "query-bearing specifiers from a served referrer".
it("drops the query when a local import names a file", function (done) {
import("~/esm/relative/query-entry.mjs?v=entry").then(function (module) {
expect(module.viaDefault).toBe("relative-import-success");
expect(module.viaNamed).toBe("relative-import-success");
return module.loadWithQuery();
}).then(function (dependency) {
expect(dependency.relativeValue).toBe("relative-import-success");
done();
}).catch(function (error) {
reportRejection(error, done);
});
});

it("should surface helpful errors for unresolved bare specifiers", function (done) {
import("bare-spec-example").then(function (mod) {
// A placeholder module default-exports a Proxy whose get trap
Expand All @@ -93,6 +109,76 @@ describe("HTTP ESM Loader", function () {
});
});

// A served module's relative and root-absolute imports resolve against its
// URL, and a query on them is part of the resulting module's identity:
// `/esm/query.mjs?v=a` and `/esm/query.mjs` are two modules to the server,
// exactly as `/ns/asm?path=...` and `/ns/asm` are to a dev server. Every
// specifier shape must reach the server with its query intact.
describe("query-bearing specifiers from a served referrer", function () {
useHttpTimeout();

var nsModule = require("ns:module");
var formsUrl = origin + "/esm/query-forms.mjs";

afterEach(function () {
nsModule.configureLoader({ importMap: { imports: {} } });
});

it("keeps the query on static root-absolute and relative imports", function (done) {
withTimeout(import(formsUrl), 10000, "import " + formsUrl)
.then(function (mod) {
expect(mod.path).toBe("/esm/query.mjs");
expect(mod.query).toContain("v=root-abs");
expect(mod.relativeQuery).toContain("v=relative");
// `export *` and `export { default }` name one URL, so
// they share one evaluated instance.
expect(mod.default.query).toContain("v=root-abs");
expect(mod.default.evaluatedAt).toBe(mod.evaluatedAt);
done();
})
.catch(function (error) {
reportRejection(error, done);
});
});

it("keeps the query on dynamic root-absolute and relative imports", function (done) {
var forms;
withTimeout(import(formsUrl), 10000, "import " + formsUrl)
.then(function (mod) {
forms = mod;
return withTimeout(forms.loadRootAbs(), 10000, "dynamic root-absolute import");
})
.then(function (rootAbs) {
expect(rootAbs.path).toBe("/esm/query.mjs");
expect(rootAbs.query).toContain("v=dyn-root");
return withTimeout(forms.loadRelative(), 10000, "dynamic relative import");
})
.then(function (relative) {
expect(relative.path).toBe("/esm/query.mjs");
expect(relative.query).toContain("v=dyn-rel");
done();
})
.catch(function (error) {
reportRejection(error, done);
});
});

it("keeps the query through an import-map prefix entry", function (done) {
nsModule.configureLoader({
importMap: { imports: { "ns-test-esm/": origin + "/esm/" } },
});
withTimeout(import("ns-test-esm/query.mjs?v=prefix"), 10000, "prefix-mapped import")
.then(function (mod) {
expect(mod.path).toBe("/esm/query.mjs");
expect(mod.query).toContain("v=prefix");
done();
})
.catch(function (error) {
reportRejection(error, done);
});
});
});

describe("HTTP Fetch Integration", function () {

it("settles a local dynamic import issued from a background thread", function (done) {
Expand Down
15 changes: 15 additions & 0 deletions test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,21 @@ private static void route(Socket socket, String path, String query) throws IOExc
return;
}

if ("/esm/query-forms.mjs".equals(path)) {
// Every specifier shape a served module can use to reach a sibling
// whose query is its identity. The server must receive each query
// intact: `/esm/query.mjs?v=x` and `/esm/query.mjs` are different
// modules to it, as `/ns/asm?path=...` and `/ns/asm` are to a dev
// server.
String body = "export * from \"/esm/query.mjs?v=root-abs\";\n"
+ "export { default } from \"/esm/query.mjs?v=root-abs\";\n"
+ "export { query as relativeQuery } from \"./query.mjs?v=relative\";\n"
+ "export function loadRootAbs() { return import(\"/esm/query.mjs?v=dyn-root\"); }\n"
+ "export function loadRelative() { return import(\"./query.mjs?v=dyn-rel\"); }\n";
respond(socket, "200 OK", JS_MIME, body.getBytes(UTF8));
return;
}

if ("/esm/html-fallback.mjs".equals(path)) {
// The SPA-fallback shape: an unknown path answered with the index
// document, 200 OK. The module loader must reject it on MIME rather
Expand Down
55 changes: 31 additions & 24 deletions test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1147,16 +1147,6 @@ static ModuleResolution ResolveSpecifierToPath(const std::string& rawSpec,
spec.insert(6, "/");
}

// Query and fragment only mean something to a server, so a non-http
// specifier drops them before anything looks it up. Applied here, in the one
// seam both import forms go through, so `./x.js?v=1` names the same module
// whether it arrives as a static import or an import().
if (!(StartsWith(spec, "http://") || StartsWith(spec, "https://"))) {
size_t cut = spec.find_first_of("?#");
if (cut != std::string::npos) spec = spec.substr(0, cut);
if (spec.empty()) return result;
}

TNS_DEBUG(Esm, "[resolver][spec] %s", spec.c_str());

// The import map is consulted before any other resolution: bare specifiers
Expand Down Expand Up @@ -1223,6 +1213,24 @@ static ModuleResolution ResolveSpecifierToPath(const std::string& rawSpec,
}
}

// A query or fragment is URL syntax, never part of a file name, so
// `import './x.js?v=1'` names x.js on disk. It is dropped only here, after
// every HTTP outcome has returned: for a served module the query is part of
// its identity (`/ns/asm?path=A` and `/ns/asm` are two modules to the
// server), and that holds for a root-absolute or relative specifier just as
// it does for an absolute URL. Applied in this one seam, so a static import
// and an import() of `./x.js?v=1` name the same file.
{
size_t cut = spec.find_first_of("?#");
if (cut != std::string::npos) {
std::string stripped = spec.substr(0, cut);
TNS_DEBUG(Esm, "[resolver][strip-query] %s -> %s", spec.c_str(),
stripped.c_str());
spec = stripped;
}
if (spec.empty()) return result;
}

// Build the filesystem candidates for this specifier shape. The specifier may
// omit its extension or name a directory, so each candidate is probed with
// Node-style extension and index fallbacks below.
Expand Down Expand Up @@ -2533,21 +2541,10 @@ v8::MaybeLocal<v8::Promise> ImportModuleDynamicallyCallback(
return builtinScope.Escape(builtinResolver->GetPromise());
}

// The specifier reaches the shared seam verbatim. Whether its query is
// identity (a served module) or noise (a file) is the seam's decision, made
// the same way for a static import and an import().
std::string normalizedSpec = rawSpec;
// remove query/hash ONLY for non-HTTP specs
bool isHttpLike =
(!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") ||
StartsWith(normalizedSpec, "https://")));
if (!isHttpLike) {
size_t qpos = normalizedSpec.find_first_of("?#");
if (qpos != std::string::npos) {
normalizedSpec = normalizedSpec.substr(0, qpos);
}
}
if (normalizedSpec != rawSpec) {
TNS_DEBUG(Esm, "[dyn-import][normalize] %s -> %s", rawSpec.c_str(),
normalizedSpec.c_str());
}

v8::EscapableHandleScope scope(isolate);

Expand Down Expand Up @@ -2579,6 +2576,16 @@ v8::MaybeLocal<v8::Promise> ImportModuleDynamicallyCallback(
TNS_DEBUG(Esm, "[dyn-import][import-map] rewrite: %s -> %s",
rawSpec.c_str(), normalizedSpec.c_str());
}
// A relative or root-absolute specifier from a served referrer resolves to
// a URL the specifier itself never spells out. Routing on that URL keeps
// such an import() on the async fetch path with its absolute-URL siblings.
if (dynamicResolution.kind == ModuleResolution::Kind::kHttp &&
!dynamicResolution.url.empty() &&
dynamicResolution.url != normalizedSpec) {
TNS_DEBUG(Esm, "[dyn-import][http-rel] %s -> %s", normalizedSpec.c_str(),
dynamicResolution.url.c_str());
normalizedSpec = dynamicResolution.url;
}

try {
// ── Blob URL support (e.g. blob:nativescript/<uuid>) ──
Expand Down