spring-projects / spring-boot

Spring Boot
https://spring.io/projects/spring-boot
Apache License 2.0
74.93k stars 40.63k forks source link

Add a utility for resolving NPM resources from webjars and CDNs #28715

Open dsyer opened 2 years ago

dsyer commented 2 years ago

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.

dsyer commented 2 years ago

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;
    }

}