package dev.penguin.closured; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.javascript.jscomp.CompilationLevel; import com.google.javascript.jscomp.CommandLineRunner; import com.google.javascript.jscomp.CompilerOptions; import com.google.javascript.jscomp.JSError; import com.google.javascript.jscomp.Result; import com.google.javascript.jscomp.SourceFile; import com.google.javascript.jscomp.WarningLevel; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; public final class Main { private record InlineSource(String name, String source) {} private static final ObjectMapper JSON = new ObjectMapper(); private static final Path ROOT = Path.of(System.getenv().getOrDefault("CLOSURED_ROOT", ".")).toAbsolutePath().normalize(); private static final int PORT = Integer.parseInt(System.getenv().getOrDefault("CLOSURED_PORT", "8080")); private static final int WORKERS = Integer.parseInt(System.getenv().getOrDefault("CLOSURED_WORKERS", "2")); public static void main(String[] args) throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", PORT), 128); server.setExecutor(Executors.newFixedThreadPool(WORKERS)); server.createContext("/health", Main::health); server.createContext("/compile", Main::compile); server.start(); System.err.printf("closure-api listening on http://0.0.0.0:%d root=%s workers=%d%n", PORT, ROOT, WORKERS); } private static void health(HttpExchange ex) throws IOException { sendJson(ex, 200, JSON.createObjectNode().put("ok", true)); } private static void compile(HttpExchange ex) throws IOException { if (!"POST".equalsIgnoreCase(ex.getRequestMethod())) { sendJson(ex, 405, JSON.createObjectNode().put("ok", false).put("error", "POST required")); return; } JsonNode req; try (InputStream in = ex.getRequestBody()) { req = JSON.readTree(in); } catch (Exception e) { sendJson(ex, 400, JSON.createObjectNode().put("ok", false).put("error", "invalid JSON")); return; } try { CompileOutput out = compileRequest(req); ObjectNode res = JSON.createObjectNode(); res.put("ok", out.success); res.put("js", out.js); res.set("warnings", JSON.valueToTree(out.warnings)); res.set("errors", JSON.valueToTree(out.errors)); sendJson(ex, out.success ? 200 : 422, res); } catch (BadRequest e) { sendJson(ex, 400, JSON.createObjectNode().put("ok", false).put("error", e.getMessage())); } catch (Exception e) { e.printStackTrace(); sendJson(ex, 500, JSON.createObjectNode().put("ok", false).put("error", e.toString())); } } private static CompileOutput compileRequest(JsonNode req) throws IOException, BadRequest { CompilerOptions options = new CompilerOptions(); CompilationLevel.ADVANCED_OPTIMIZATIONS.setOptionsForCompilationLevel(options); WarningLevel.DEFAULT.setOptionsForWarningLevel(options); options.setEnvironment(CompilerOptions.Environment.BROWSER); options.setLanguageIn(CompilerOptions.LanguageMode.ECMASCRIPT_NEXT); options.setLanguageOut(CompilerOptions.LanguageMode.ECMASCRIPT_2019); List externs = new ArrayList<>(); externs.addAll(CommandLineRunner.getBuiltinExterns(options.getEnvironment())); externs.addAll(readFiles(req, "externs")); externs.addAll(readInlineSources(req, "externSources")); List inputs = new ArrayList<>(); inputs.addAll(readFiles(req, "js")); inputs.addAll(readInlineSources(req, "jsSources")); if (inputs.isEmpty()) { throw new BadRequest("request must include at least one js file or js source"); } com.google.javascript.jscomp.Compiler compiler = new com.google.javascript.jscomp.Compiler(); JsonNode defines = req.get("defines"); if (defines != null && defines.isObject()) { Iterator> fields = defines.fields(); while (fields.hasNext()) { Map.Entry entry = fields.next(); String name = entry.getKey(); JsonNode value = entry.getValue(); if (value.isBoolean()) { options.setDefineToBooleanLiteral(name, value.booleanValue()); } else if (value.isNumber()) { if (!value.canConvertToInt()) { throw new BadRequest("numeric define must be an integer: " + name); } options.setDefineToNumberLiteral(name, value.intValue()); } else if (value.isTextual()) { options.setDefineToStringLiteral(name, value.textValue()); } else { throw new BadRequest("define must be boolean, number, or string: " + name); } } } Result result = compiler.compile(externs, inputs, options); return new CompileOutput( result.success, result.success ? compiler.toSource() : "", toStrings(compiler.getWarnings()), toStrings(compiler.getErrors()) ); } private static List readFiles(JsonNode req, String field) throws IOException, BadRequest { List out = new ArrayList<>(); JsonNode arr = req.get(field); if (arr == null) { return out; } if (!arr.isArray()) { throw new BadRequest(field + " must be an array"); } for (JsonNode item : arr) { if (!item.isTextual()) { throw new BadRequest(field + " entries must be strings"); } Path p = safePath(item.textValue()); out.add(SourceFile.fromFile(p.toString())); } return out; } private static List readInlineSources(JsonNode req, String field) throws BadRequest { JsonNode arr = req.get(field); List out = new ArrayList<>(); if (arr == null || arr.isNull()) { return out; } if (!arr.isArray()) { throw new BadRequest(field + " must be an array"); } for (JsonNode item : arr) { String name; String source; if (item.isTextual()) { // Optional convenience form: // "jsSources": ["console.log(1);"] name = field + "-" + out.size() + ".js"; source = item.textValue(); } else if (item.isObject()) { JsonNode nameNode = item.get("name"); JsonNode sourceNode = item.get("source"); if (sourceNode == null || !sourceNode.isTextual()) { throw new BadRequest(field + " item must include textual source"); } if (nameNode != null && nameNode.isTextual() && !nameNode.textValue().isBlank()) { name = nameNode.textValue(); } else { name = field + "-" + out.size() + ".js"; } source = sourceNode.textValue(); } else { throw new BadRequest(field + " items must be strings or objects"); } out.add(SourceFile.fromCode(name, source)); } return out; } private static Path safePath(String value) throws BadRequest { Path p = ROOT.resolve(value).normalize(); if (!p.startsWith(ROOT)) { throw new BadRequest("path escapes root: " + value); } if (!Files.isRegularFile(p)) { throw new BadRequest("file not found: " + value); } return p; } private static List toStrings(Iterable errors) { List out = new ArrayList<>(); for (JSError e : errors) { out.add(e.toString()); } return out; } private static void sendJson(HttpExchange ex, int status, JsonNode body) throws IOException { byte[] bytes = JSON.writerWithDefaultPrettyPrinter().writeValueAsBytes(body); ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); ex.sendResponseHeaders(status, bytes.length); try (OutputStream os = ex.getResponseBody()) { os.write(bytes); } } private record CompileOutput(boolean success, String js, List warnings, List errors) {} private static final class BadRequest extends Exception { BadRequest(String message) { super(message); } } }