forked from Botanical/BotanJS
Rename the components
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
ARG JAVA_SRC_DIR
|
||||
ARG CLOSURE_NAME
|
||||
|
||||
# Copy pom first so Docker can cache dependencies.
|
||||
COPY $JAVA_SRC_DIR/pom.xml .
|
||||
|
||||
RUN --mount=type=cache,target=/root/.m2 \
|
||||
mvn -B -DskipTests dependency:go-offline
|
||||
|
||||
COPY $JAVA_SRC_DIR/src ./src
|
||||
|
||||
RUN --mount=type=cache,target=/root/.m2 \
|
||||
mvn -B -DskipTests package
|
||||
|
||||
FROM eclipse-temurin:21-jre
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG JAVA_SRC_DIR
|
||||
ARG CLOSURE_NAME
|
||||
|
||||
RUN useradd -r -u 10001 closure
|
||||
|
||||
COPY --from=build /src/target/${CLOSURE_NAME}-0.1.0.jar /app/runtime.jar
|
||||
COPY $JAVA_SRC_DIR/example ./example
|
||||
|
||||
USER closure
|
||||
|
||||
ENV CLOSURED_ROOT=/work
|
||||
ENV CLOSURED_PORT=8080
|
||||
ENV CLOSURED_WORKERS=2
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["java", "-Xms256m", "-Xmx2g", "-jar", "/app/runtime.jar"]
|
||||
@@ -0,0 +1,30 @@
|
||||
# closure-api-min
|
||||
|
||||
Tiny Closure Compiler HTTP daemon using Java's built-in HTTP server.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
mvn -DskipTests package
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
CLOSURED_ROOT=$PWD \
|
||||
CLOSURED_PORT=8080 \
|
||||
CLOSURED_WORKERS=2 \
|
||||
java -Xms256m -Xmx2g -jar target/closure-api-0.1.0.jar
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
curl -s http://127.0.0.1:8080/compile \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"externs": ["example/externs/browser.js"],
|
||||
"js": ["example/js/hello.js"],
|
||||
"defines": {"DEBUG": false}
|
||||
}'
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
/** @externs */
|
||||
var window = {};
|
||||
window.demoHello = function(name) {};
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @define {boolean} */
|
||||
var DEBUG = false;
|
||||
|
||||
function internalNameThatShouldDisappear(name) {
|
||||
if (DEBUG) {
|
||||
console.log('debug mode');
|
||||
}
|
||||
return 'hello ' + name;
|
||||
}
|
||||
|
||||
window.demoHello = function(name) {
|
||||
return internalNameThatShouldDisappear(name);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>dev.tgckpg</groupId>
|
||||
<artifactId>closure-api</artifactId>
|
||||
<version>0.1.0</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.google.javascript</groupId>
|
||||
<artifactId>closure-compiler</artifactId>
|
||||
<version>v20260526</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.19.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.14.0</version>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>dev.tgckpg.closured.Main</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,192 @@
|
||||
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 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"));
|
||||
|
||||
List<SourceFile> inputs = readFiles(req, "js");
|
||||
|
||||
if (inputs.isEmpty()) {
|
||||
throw new BadRequest("request must include at least one js file");
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user