Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
dsyer opened this issue Nov 17, 2021 · 1 comment
Open

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

dsyer opened this issue Nov 17, 2021 · 1 comment
Labels
status: pending-design-work Needs design work before any code can be developed type: enhancement A general enhancement

Comments

@dsyer
Copy link
Member

dsyer commented Nov 17, 2021

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.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Nov 17, 2021
@dsyer
Copy link
Member Author

dsyer commented Nov 17, 2021

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

}

@philwebb philwebb added for: team-meeting An issue we'd like to discuss as a team to make progress type: enhancement A general enhancement status: pending-design-work Needs design work before any code can be developed and removed status: waiting-for-triage An issue we've not yet triaged for: team-meeting An issue we'd like to discuss as a team to make progress labels Nov 18, 2021
@philwebb philwebb added this to the General Backlog milestone Nov 24, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: pending-design-work Needs design work before any code can be developed type: enhancement A general enhancement
Projects
None yet
Development

No branches or pull requests

3 participants