commit a36325bcfef8afa8022d7ff4e75fecd1d120cfaa0459f424ce0c1af6e06eafc4
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-06-12T04:14:28Z |
| subject | Moved 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);
+ }
+ }
+}