penguin/closure-api

google closure api wrapper

commit a36325bcfef8afa8022d7ff4e75fecd1d120cfaa0459f424ce0c1af6e06eafc4

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-06-12T04:14:28Z
subjectMoved from AstorJs
commit a36325bcfef8afa8022d7ff4e75fecd1d120cfaa0459f424ce0c1af6e06eafc4
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-06-12T04:14:28Z

    Moved from AstorJs
---
 Dockerfile                                  |  32 ++++
 README.md                                   |  37 ++++-
 concourse/build.yaml                        |  24 +++
 concourse/prepare-deployment-resources.yaml |  21 +++
 k8s/deployments.yaml                        |  40 +++++
 pom.xml                                     |  61 +++++++
 src/main/java/dev/tgckpg/closured/Main.java | 243 ++++++++++++++++++++++++++++
 7 files changed, 456 insertions(+), 2 deletions(-)

diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..c03cb16
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,32 @@
+FROM maven:3.9-eclipse-temurin-21 AS build
+
+WORKDIR /src
+
+# Copy pom first so Docker can cache dependencies.
+COPY pom.xml .
+
+RUN --mount=type=cache,target=/root/.m2 \
+    mvn -B -DskipTests dependency:go-offline
+
+COPY src ./src
+
+RUN --mount=type=cache,target=/root/.m2 \
+    mvn -B -DskipTests package
+
+FROM eclipse-temurin:21-jre
+
+WORKDIR /app
+
+RUN useradd -r -u 1001 closure
+
+COPY --from=build /src/target/*.jar /app/runtime.jar
+
+USER closure
+
+ENV CLOSURED_ROOT=/app
+ENV CLOSURED_PORT=8080
+ENV CLOSURED_WORKERS=2
+
+EXPOSE 8080
+
+ENTRYPOINT ["java", "-Xms256m", "-Xmx2g", "-jar", "/app/runtime.jar"]
diff --git a/README.md b/README.md
index b95e23b..b2bb5e8 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,36 @@
-# closure-api
+# closure-api-min
 
-A simple closure api
\ No newline at end of file
+a simple closure compiler server  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 '{
+    "externSources": [{
+        "name": "extern/hello.js",
+        "source": "var a = {};"
+    }],
+    "jsSources": [{
+        "name": "example/js/hello.js",
+        "source": "var a = {};"
+    }],
+    "defines": {"DEBUG": false}
+  }'
+```
diff --git a/concourse/build.yaml b/concourse/build.yaml
new file mode 100644
index 0000000..0fb6ac4
--- /dev/null
+++ b/concourse/build.yaml
@@ -0,0 +1,24 @@
+platform: linux
+
+image_resource:
+  type: registry-image
+  source:
+    repository: concourse/oci-build-task
+
+inputs:
+  - name: project-src
+    path: .
+
+outputs:
+  - name: image
+
+caches:
+  - path: cache
+
+params:
+  CONTEXT: .
+  # Reference: https://concourse-ci.org/building-an-image-and-using-it-in-a-task.html#build-the-image
+  UNPACK_ROOTFS: true
+
+run:
+  path: build
diff --git a/concourse/prepare-deployment-resources.yaml b/concourse/prepare-deployment-resources.yaml
new file mode 100644
index 0000000..b481c59
--- /dev/null
+++ b/concourse/prepare-deployment-resources.yaml
@@ -0,0 +1,21 @@
+platform: linux
+
+image_resource:
+  type: registry-image
+  source:
+    repository: alpine
+
+inputs:
+  - name: project-src
+    path: .
+
+outputs:
+  - name: deploy-confs
+
+run:
+  path: sh
+  args:
+    - -exc
+    - |
+      VERSION=$( cat commit.sha )
+      sed "s/IMAGE_TAG/$VERSION/g" k8s/deployments.yaml >> deploy-confs/prod.yaml
diff --git a/k8s/deployments.yaml b/k8s/deployments.yaml
new file mode 100644
index 0000000..ebb3b94
--- /dev/null
+++ b/k8s/deployments.yaml
@@ -0,0 +1,40 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: closure-api
+spec:
+  selector:
+    matchLabels:
+      app: closure-api
+  replicas: 1
+  template:
+    metadata:
+      labels:
+        app: closure-api
+    spec:
+      imagePullSecrets:
+        - name: registry-auth
+      containers:
+        - name: web
+          image: registry.k8s.astropenguin.net/closure-api:IMAGE_TAG
+          securityContext:
+            runAsGroup: 1001
+            runAsNonRoot: true
+            runAsUser: 1001
+          env:
+            - name: CLOSURE_PORT
+              value: 8080
+            - name: CLOSURE_WORKERS
+              value: 2
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: closure-api
+spec:
+  selector:
+    app: closure-api
+  ports:
+    - port: 8080
+      targetPort: 8080
+      name: web
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..add9f59
--- /dev/null
+++ b/pom.xml
@@ -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.penguin</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.penguin.closured.Main</mainClass>
+                </transformer>
+              </transformers>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git a/src/main/java/dev/tgckpg/closured/Main.java b/src/main/java/dev/tgckpg/closured/Main.java
new file mode 100644
index 0000000..9e287f7
--- /dev/null
+++ b/src/main/java/dev/tgckpg/closured/Main.java
@@ -0,0 +1,243 @@
+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<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);
+        }
+    }
+}