JavaScript in the browser has come a long way and native support for ECMAScript (aka es6 or esm) modules is becoming ubiquitous. Even without explicit support there is a shim that lets you use modules in all browsers with a one line import. The thing that is missing for most Spring Boot apps is ease of use in importing those modules into an HTML page or template.
Note that <script type="importmap">
is a standard feature in browsers, but there is no uniform way to write the paths because the modules can come from literally any URL. Something convention-driven seems to make sense, and since we also support webjars in other ways, something with webjars appeals to me too.
I would like to be able to do this in HTML (note the /npm/*
paths in the map values - arbitrary, but convenient):
<script type="importmap">
{
"imports": {
"bootstrap": "/npm/bootstrap",
"@popperjs/core": "/npm/@popperjs/core",
"htmx": "/npm/htmx.org"
}
}
</script>
and then be able to do this (i.e. just use modules like you do in Node.js):
<script type="module">
import 'bootstrap';
import 'htmx';
</script>
The thing that would enable this is probably best implemented as a @RequestMapping
.
Comment From: dsyer
Here's a prototype (https://github.com/dsyer/npm-resolver) that looks for webjars and falls back to unpkg.com if it can't find one:
@RestController
public class NpmVersionResolver {
private static final Log logger = LogFactory.getLog(NpmVersionResolver.class);
private static final Set<String> ALERTS = new HashSet<>();
private static final String PROPERTIES_ROOT = "META-INF/maven/";
private static final String RESOURCE_ROOT = "META-INF/resources/webjars/";
private static final String NPM = "org.webjars.npm/";
private static final String PLAIN = "org.webjars/";
private static final String POM_PROPERTIES = "/pom.properties";
private static final String PACKAGE_JSON = "/package.json";
@GetMapping("/npm/{webjar}")
public ResponseEntity<Void> module(@PathVariable String webjar) {
String path = findWebJarResourcePath(webjar, "/");
if (path == null) {
path = findUnpkgPath(webjar, "");
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(path)).build();
}
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create("/webjars/" + path)).build();
}
@GetMapping("/npm/{webjar}/{*remainder}")
public ResponseEntity<Void> remainder(@PathVariable String webjar, @PathVariable String remainder) {
if (webjar.startsWith("@")) {
int index = remainder.indexOf("/",1);
String path = index < 0 ? remainder.substring(1) : remainder.substring(1, index);
webjar = webjar.substring(1) + "__" + path;
if (index < 0 || index == remainder.length() - 1) {
return module(webjar);
}
remainder = remainder.substring(index);
}
String path = findWebJarResourcePath(webjar, remainder);
if (path == null) {
if (version(webjar) == null) {
path = findUnpkgPath(webjar, remainder);
} else {
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(path)).build();
}
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create("/webjars/" + path)).build();
}
private String findUnpkgPath(String webjar, String remainder) {
if (!StringUtils.hasText(remainder)) {
remainder = "";
} else if (!remainder.startsWith("/")) {
remainder = "/" + remainder;
}
if (webjar.contains("__")) {
webjar = "@" + webjar.replace("__", "/");
}
if (logger.isInfoEnabled() && !ALERTS.contains(webjar)) {
ALERTS.add(webjar);
logger.info("Resolving webjar to unpkg.com: " + webjar);
}
return "https://unpkg.com/" + webjar + remainder;
}
@Nullable
protected String findWebJarResourcePath(String webjar, String path) {
if (webjar.length() > 0) {
String version = version(webjar);
if (version != null) {
String partialPath = path(webjar, version, path);
if (partialPath != null) {
String webJarPath = webjar + "/" + version + partialPath;
return webJarPath;
}
}
}
return null;
}
private String path(String webjar, String version, String path) {
if (path.equals("/")) {
String module = module(webjar, version, path);
if (module != null) {
return module;
} else {
return null;
}
}
if (path.equals("/main.js")) {
String module = module(webjar, version, path);
if (module != null) {
return module;
}
}
if (new ClassPathResource(RESOURCE_ROOT + webjar + "/" + version + path).isReadable()) {
return path;
}
return null;
}
private String module(String webjar, String version, String path) {
Resource resource = new ClassPathResource(RESOURCE_ROOT + webjar + "/" + version + PACKAGE_JSON);
if (resource.isReadable()) {
try {
JsonParser parser = JsonParserFactory.getJsonParser();
Map<String, Object> map = parser
.parseMap(StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8));
if (!path.equals("/main.js") && map.containsKey("module")) {
return "/" + (String) map.get("module");
}
if (!map.containsKey("main") && map.containsKey("jspm")) {
String stem = resolve(map, "jspm.directories.lib", "dist");
String main = resolve(map, "jspm.main", "index.js");
return "/" + stem + "/" + main + (main.endsWith(".js") ? "" : ".js");
}
return "/" + (String) map.get("main");
} catch (IOException e) {
}
}
return null;
}
private static String resolve(Map<String, Object> map, String path, String defaultValue) {
Map<String, Object> sub = map;
String[] elements = StringUtils.delimitedListToStringArray(path, ".");
for (int i = 0; i < elements.length - 1; i++) {
@SuppressWarnings("unchecked")
Map<String, Object> tmp = (Map<String, Object>) sub.get(elements[i]);
sub = tmp;
if (sub == null) {
return defaultValue;
}
}
return (String) sub.getOrDefault(elements[elements.length - 1], defaultValue);
}
private String version(String webjar) {
Resource resource = new ClassPathResource(PROPERTIES_ROOT + NPM + webjar + POM_PROPERTIES);
if (!resource.isReadable()) {
resource = new ClassPathResource(PROPERTIES_ROOT + PLAIN + webjar + POM_PROPERTIES);
}
if (resource.isReadable()) {
Properties properties;
try {
properties = PropertiesLoaderUtils.loadProperties(resource);
return properties.getProperty("version");
} catch (IOException e) {
}
}
return null;
}
}