penguin/AstroJS

Javascript framework for my blog

closure-api/src/main/java/dev/tgckpg/closured/Main.java

raw ยท 9401 bytes

package dev.tgckpg.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<SourceFile> externs = new ArrayList<>();
        externs.addAll(CommandLineRunner.getBuiltinExterns(options.getEnvironment()));
        externs.addAll(readFiles(req, "externs"));
        externs.addAll(readInlineSources(req, "externSources"));

        List<SourceFile> 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<Map.Entry<String, JsonNode>> fields = defines.fields();
            while (fields.hasNext()) {
                Map.Entry<String, JsonNode> 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<SourceFile> readFiles(JsonNode req, String field) throws IOException, BadRequest {
        List<SourceFile> 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<SourceFile> readInlineSources(JsonNode req, String field) throws BadRequest {
        JsonNode arr = req.get(field);
        List<SourceFile> 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<String> toStrings(Iterable<JSError> errors) {
        List<String> 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<String> warnings, List<String> errors) {}

    private static final class BadRequest extends Exception {
        BadRequest(String message) {
            super(message);
        }
    }
}