diff --git a/backend/Dockerfile b/backend/Dockerfile
index 8e9be3f4..d5661be1 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,57 +1,93 @@
-# Etapa de build
+# Build stage
FROM golang:1.23-alpine AS builder
-# Instalar dependencias necesarias
RUN apk add --no-cache git openjdk17 curl
-# Establecer directorio de trabajo
+# ── Go binary ────────────────────────────────────────────────────────────────
WORKDIR /app/src
-
-# Copiar los archivos
COPY src/ .
+RUN go mod tidy && go build -o server .
-# Compilar el binario estático
-RUN go mod tidy && \
- go build -o server .
-
-# Build extension functions jar + SaxonDaemon
+# ── Java daemons ─────────────────────────────────────────────────────────────
WORKDIR /app/ext
COPY ext/ .
-RUN mkdir -p /tmp/saxon && \
- curl -L -o /tmp/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \
- curl -L -o /tmp/saxon/gson.jar https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar && \
- javac -cp "/tmp/saxon/saxon-he.jar:/tmp/saxon/gson.jar" \
+
+# Download JARs for Saxon 12 (XSLT 3.0)
+RUN mkdir -p /tmp/saxon12 && \
+ curl -L -o /tmp/saxon12/saxon-he.jar \
+ https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \
+ curl -L -o /tmp/saxon12/gson.jar \
+ https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar
+
+# Download JARs for Saxon 9 (XSLT 2.0)
+RUN mkdir -p /tmp/saxon9 && \
+ curl -L -o /tmp/saxon9/saxon-he.jar \
+ https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/9.9.1.8/Saxon-HE-9.9.1.8.jar && \
+ curl -L -o /tmp/saxon9/gson.jar \
+ https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar
+
+# Compile SaxonDaemon + Runner + CustomFunctions against Saxon 12
+RUN mkdir -p /tmp/classes12 && \
+ javac -cp "/tmp/saxon12/saxon-he.jar:/tmp/saxon12/gson.jar" \
+ -d /tmp/classes12 \
com/xsltplayground/ext/CustomFunctions.java \
com/xsltplayground/Runner.java \
com/xsltplayground/SaxonDaemon.java && \
- jar cf custom-functions.jar com
+ jar cf /tmp/custom-functions-12.jar -C /tmp/classes12 .
+
+# Compile Saxon2Daemon + Runner + CustomFunctions against Saxon 9
+RUN mkdir -p /tmp/classes9 && \
+ javac -cp "/tmp/saxon9/saxon-he.jar:/tmp/saxon9/gson.jar" \
+ -d /tmp/classes9 \
+ com/xsltplayground/ext/CustomFunctions.java \
+ com/xsltplayground/Runner.java \
+ com/xsltplayground/Saxon2Daemon.java && \
+ jar cf /tmp/custom-functions-9.jar -C /tmp/classes9 .
+
+# Compile XalanDaemon — JDK built-in XSLT 1.0 processor, only gson needed
+RUN mkdir -p /tmp/classesxalan && \
+ javac -cp "/tmp/saxon12/gson.jar" \
+ -d /tmp/classesxalan \
+ com/xsltplayground/XalanDaemon.java && \
+ jar cf /tmp/custom-functions-xalan.jar -C /tmp/classesxalan .
WORKDIR /app/src
-# Etapa final (runtime)
+# Runtime stage
FROM alpine:latest
-# Instalar Java y wget (para el health-check en start.sh)
RUN apk add --no-cache openjdk17-jre curl wget
-# Crear directorio de trabajo
WORKDIR /app
-# Copiar binario, configuración y script de arranque
COPY --from=builder /app/src/server .
COPY app.config .
COPY start.sh .
RUN chmod +x start.sh
-# Crear carpeta para Saxon y descargar JARs
-RUN mkdir -p /opt/saxon && \
- curl -L -o /opt/saxon/saxon-he.jar https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \
- curl -L -o /opt/saxon/xmlresolver.jar https://repo1.maven.org/maven2/org/xmlresolver/xmlresolver/4.5.0/xmlresolver-4.5.0.jar && \
- curl -L -o /opt/saxon/gson.jar https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar
+# Saxon 12 (XSLT 3.0) — port 8081
+RUN mkdir -p /opt/saxon12 && \
+ curl -L -o /opt/saxon12/saxon-he.jar \
+ https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar && \
+ curl -L -o /opt/saxon12/xmlresolver.jar \
+ https://repo1.maven.org/maven2/org/xmlresolver/xmlresolver/4.5.0/xmlresolver-4.5.0.jar && \
+ curl -L -o /opt/saxon12/gson.jar \
+ https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar
+COPY --from=builder /tmp/custom-functions-12.jar /opt/saxon12/
-# Extension functions + daemon jar
-COPY --from=builder /app/ext/custom-functions.jar /opt/saxon/
+# Saxon 9 (XSLT 2.0) — port 8083
+RUN mkdir -p /opt/saxon9 && \
+ curl -L -o /opt/saxon9/saxon-he.jar \
+ https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/9.9.1.8/Saxon-HE-9.9.1.8.jar && \
+ curl -L -o /opt/saxon9/gson.jar \
+ https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar
+COPY --from=builder /tmp/custom-functions-9.jar /opt/saxon9/
+
+# XSLTC / JDK built-in (XSLT 1.0) — port 8082
+RUN mkdir -p /opt/xalan && \
+ curl -L -o /opt/xalan/gson.jar \
+ https://repo1.maven.org/maven2/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar
+COPY --from=builder /tmp/custom-functions-xalan.jar /opt/xalan/
-# Exponer el puerto del API y arrancar con el script
EXPOSE 8000
CMD ["./start.sh"]
diff --git a/backend/ext/com/xsltplayground/Saxon2Daemon.java b/backend/ext/com/xsltplayground/Saxon2Daemon.java
new file mode 100644
index 00000000..e1821d53
--- /dev/null
+++ b/backend/ext/com/xsltplayground/Saxon2Daemon.java
@@ -0,0 +1,183 @@
+package com.xsltplayground;
+
+import com.google.gson.*;
+import com.sun.net.httpserver.*;
+import com.xsltplayground.ext.CustomFunctions;
+import net.sf.saxon.lib.FeatureKeys;
+import net.sf.saxon.s9api.*;
+
+import javax.xml.transform.stream.StreamSource;
+import java.io.*;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.*;
+
+/**
+ * XSLT 2.0 daemon using Saxon HE 9.x.
+ * Runs on port 8083. Compiled and run against Saxon 9 JARs.
+ */
+public class Saxon2Daemon {
+
+ static final Processor PROCESSOR;
+ static final Processor TRACE_PROCESSOR;
+ private static final Gson GSON = new Gson();
+
+ static {
+ PROCESSOR = new Processor(false);
+ CustomFunctions.registerAll(PROCESSOR);
+
+ TRACE_PROCESSOR = new Processor(false);
+ TRACE_PROCESSOR.setConfigurationProperty(FeatureKeys.OPTIMIZATION_LEVEL, "0");
+ CustomFunctions.registerAll(TRACE_PROCESSOR);
+
+ String warmupXslt =
+ "" +
+ "";
+ String warmupXml = "";
+ for (Processor proc : new Processor[]{PROCESSOR, TRACE_PROCESSOR}) {
+ try {
+ XsltCompiler c = proc.newXsltCompiler();
+ XsltExecutable exec = c.compile(new StreamSource(new StringReader(warmupXslt)));
+ XsltTransformer t = exec.load();
+ XdmNode doc = proc.newDocumentBuilder()
+ .build(new StreamSource(new StringReader(warmupXml)));
+ t.setInitialContextNode(doc);
+ Serializer ser = proc.newSerializer(new StringWriter());
+ t.setDestination(ser);
+ t.transform();
+ } catch (Exception e) {
+ System.err.println("Saxon2Daemon warm-up warning: " + e.getMessage());
+ }
+ }
+ System.out.println("Saxon2Daemon: warm-up complete.");
+ }
+
+ public static void main(String[] args) throws Exception {
+ int port = 8083;
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 32);
+ server.createContext("/transform", new TransformHandler());
+ server.createContext("/health", exchange -> {
+ byte[] resp = "ok".getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "text/plain");
+ exchange.sendResponseHeaders(200, resp.length);
+ try (OutputStream os = exchange.getResponseBody()) { os.write(resp); }
+ });
+ int threads = Math.max(2, Runtime.getRuntime().availableProcessors());
+ server.setExecutor(Executors.newFixedThreadPool(threads));
+ server.start();
+ System.out.println("Saxon2Daemon ready on :" + port + " (threads=" + threads + ")");
+ }
+
+ static class TransformHandler implements HttpHandler {
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ if (!exchange.getRequestMethod().equalsIgnoreCase("POST")) {
+ exchange.sendResponseHeaders(405, -1);
+ exchange.close();
+ return;
+ }
+
+ String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ JsonObject response = new JsonObject();
+ int status = 200;
+
+ try {
+ JsonObject req = GSON.fromJson(body, JsonObject.class);
+
+ String xslt = req.has("xslt") ? req.get("xslt").getAsString() : "";
+ String source = req.has("source") ? req.get("source").getAsString() : "";
+ boolean trace = req.has("trace") && req.get("trace").getAsBoolean();
+
+ Map params = jsonObjectToMap(req, "parameters");
+ Map fileParams = jsonObjectToMap(req, "fileParameters");
+
+ Processor proc = trace ? TRACE_PROCESSOR : PROCESSOR;
+
+ ByteArrayOutputStream traceBuf = new ByteArrayOutputStream();
+ PrintStream traceSink = new PrintStream(traceBuf, true, StandardCharsets.UTF_8);
+
+ XsltCompiler compiler = proc.newXsltCompiler();
+ compiler.setErrorReporter(
+ new Runner.DeduplicatingErrorReporter(compiler.getErrorReporter()));
+
+ boolean instrumentationEnabled = false;
+ if (trace) {
+ instrumentationEnabled = Runner.enableCompileWithTracing(compiler);
+ }
+
+ XsltExecutable exec;
+ try {
+ exec = compiler.compile(new StreamSource(new StringReader(xslt)));
+ } catch (SaxonApiException e) {
+ if (trace && instrumentationEnabled) {
+ compiler = proc.newXsltCompiler();
+ compiler.setErrorReporter(
+ new Runner.DeduplicatingErrorReporter(compiler.getErrorReporter()));
+ exec = compiler.compile(new StreamSource(new StringReader(xslt)));
+ } else {
+ throw e;
+ }
+ }
+
+ XsltTransformer transformer = exec.load();
+
+ if (trace) {
+ Runner.attachTraceListener(proc, transformer, traceSink);
+ }
+
+ if (source != null && !source.isEmpty()) {
+ XdmNode doc = proc.newDocumentBuilder()
+ .build(new StreamSource(new StringReader(source)));
+ transformer.setInitialContextNode(doc);
+ }
+
+ for (Map.Entry e : params.entrySet()) {
+ transformer.setParameter(new QName(e.getKey()), new XdmAtomicValue(e.getValue()));
+ }
+
+ for (Map.Entry e : fileParams.entrySet()) {
+ String val = e.getValue().trim();
+ if (val.startsWith("<")) {
+ XdmNode node = proc.newDocumentBuilder()
+ .build(new StreamSource(new StringReader(val)));
+ transformer.setParameter(new QName(e.getKey()), node);
+ } else {
+ transformer.setParameter(new QName(e.getKey()), new XdmAtomicValue(val));
+ }
+ }
+
+ StringWriter resultWriter = new StringWriter();
+ Serializer ser = proc.newSerializer(resultWriter);
+ transformer.setDestination(ser);
+ transformer.transform();
+
+ traceSink.flush();
+ response.addProperty("result", resultWriter.toString());
+ response.addProperty("traceText", trace ? traceBuf.toString(StandardCharsets.UTF_8) : "");
+
+ } catch (SaxonApiException e) {
+ response.addProperty("error", e.getMessage() != null ? e.getMessage() : e.toString());
+ status = 400;
+ } catch (Exception e) {
+ response.addProperty("error", e.toString());
+ status = 500;
+ }
+
+ byte[] respBytes = GSON.toJson(response).getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(status, respBytes.length);
+ try (OutputStream os = exchange.getResponseBody()) { os.write(respBytes); }
+ }
+
+ private Map jsonObjectToMap(JsonObject req, String key) {
+ Map result = new LinkedHashMap<>();
+ if (req.has(key) && req.get(key).isJsonObject()) {
+ for (Map.Entry e : req.getAsJsonObject(key).entrySet()) {
+ result.put(e.getKey(), e.getValue().getAsString());
+ }
+ }
+ return result;
+ }
+ }
+}
diff --git a/backend/ext/com/xsltplayground/XalanDaemon.java b/backend/ext/com/xsltplayground/XalanDaemon.java
new file mode 100644
index 00000000..8ed1a23a
--- /dev/null
+++ b/backend/ext/com/xsltplayground/XalanDaemon.java
@@ -0,0 +1,142 @@
+package com.xsltplayground;
+
+import com.google.gson.*;
+import com.sun.net.httpserver.*;
+
+import javax.xml.transform.*;
+import javax.xml.transform.stream.*;
+import java.io.*;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.*;
+
+/**
+ * XSLT 1.0 daemon using the JDK's built-in XSLT processor (XSLTC / Xalan-J).
+ * Runs on port 8082. No Saxon dependency.
+ */
+public class XalanDaemon {
+
+ private static final Gson GSON = new Gson();
+ private static final int PORT = 8082;
+
+ static {
+ // Force JDK built-in XSLTC (not Saxon, which might be on the classpath)
+ System.setProperty("javax.xml.transform.TransformerFactory",
+ "com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl");
+
+ // Warm up
+ try {
+ TransformerFactory.newInstance().newTemplates(new StreamSource(new StringReader(
+ "" +
+ "")));
+ } catch (Exception e) {
+ System.err.println("XalanDaemon warm-up warning: " + e.getMessage());
+ }
+ System.out.println("XalanDaemon: warm-up complete.");
+ }
+
+ public static void main(String[] args) throws Exception {
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", PORT), 32);
+ server.createContext("/transform", new TransformHandler());
+ server.createContext("/health", exchange -> {
+ byte[] resp = "ok".getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "text/plain");
+ exchange.sendResponseHeaders(200, resp.length);
+ try (OutputStream os = exchange.getResponseBody()) { os.write(resp); }
+ });
+ int threads = Math.max(2, Runtime.getRuntime().availableProcessors());
+ server.setExecutor(Executors.newFixedThreadPool(threads));
+ server.start();
+ System.out.println("XalanDaemon ready on :" + PORT + " (threads=" + threads + ")");
+ }
+
+ static class TransformHandler implements HttpHandler {
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ if (!exchange.getRequestMethod().equalsIgnoreCase("POST")) {
+ exchange.sendResponseHeaders(405, -1);
+ exchange.close();
+ return;
+ }
+
+ String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ JsonObject response = new JsonObject();
+ int status = 200;
+
+ try {
+ JsonObject req = GSON.fromJson(body, JsonObject.class);
+
+ String xslt = req.has("xslt") ? req.get("xslt").getAsString() : "";
+ String source = req.has("source") ? req.get("source").getAsString() : "";
+
+ Map params = jsonObjectToMap(req, "parameters");
+ Map fileParams = jsonObjectToMap(req, "fileParameters");
+
+ // Collect warnings
+ StringBuilder warnings = new StringBuilder();
+ ErrorListener errorListener = new ErrorListener() {
+ @Override public void warning(TransformerException e) {
+ warnings.append("Warning: ").append(e.getMessage()).append("\n");
+ }
+ @Override public void error(TransformerException e) throws TransformerException { throw e; }
+ @Override public void fatalError(TransformerException e) throws TransformerException { throw e; }
+ };
+
+ TransformerFactory factory = TransformerFactory.newInstance();
+ factory.setErrorListener(errorListener);
+ Templates templates = factory.newTemplates(new StreamSource(new StringReader(xslt)));
+ Transformer transformer = templates.newTransformer();
+ transformer.setErrorListener(errorListener);
+
+ for (Map.Entry e : params.entrySet()) {
+ transformer.setParameter(e.getKey(), e.getValue());
+ }
+ // File params: XSLT 1.0 doesn't support node-typed params natively — pass as string
+ for (Map.Entry e : fileParams.entrySet()) {
+ transformer.setParameter(e.getKey(), e.getValue());
+ }
+
+ // XSLT 1.0 always requires a source document (no xsl:initial-template)
+ String src = (source != null && !source.isEmpty()) ? source : "";
+ StringWriter resultWriter = new StringWriter();
+ transformer.transform(new StreamSource(new StringReader(src)), new StreamResult(resultWriter));
+
+ response.addProperty("result", resultWriter.toString());
+ response.addProperty("traceText", warnings.toString());
+
+ } catch (TransformerException e) {
+ response.addProperty("error", formatError(e));
+ status = 400;
+ } catch (Exception e) {
+ response.addProperty("error", e.getMessage() != null ? e.getMessage() : e.toString());
+ status = 500;
+ }
+
+ byte[] respBytes = GSON.toJson(response).getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(status, respBytes.length);
+ try (OutputStream os = exchange.getResponseBody()) { os.write(respBytes); }
+ }
+
+ private static String formatError(TransformerException e) {
+ SourceLocator loc = e.getLocator();
+ String msg = e.getMessage() != null ? e.getMessage() : e.toString();
+ if (loc != null && loc.getLineNumber() > 0) {
+ return "Error at line " + loc.getLineNumber() +
+ ", column " + loc.getColumnNumber() + ": " + msg;
+ }
+ return msg;
+ }
+
+ private Map jsonObjectToMap(JsonObject req, String key) {
+ Map result = new LinkedHashMap<>();
+ if (req.has(key) && req.get(key).isJsonObject()) {
+ for (Map.Entry e : req.getAsJsonObject(key).entrySet()) {
+ result.put(e.getKey(), e.getValue().getAsString());
+ }
+ }
+ return result;
+ }
+ }
+}
diff --git a/backend/src/main.go b/backend/src/main.go
index 5bd41656..2ea0cdc6 100644
--- a/backend/src/main.go
+++ b/backend/src/main.go
@@ -218,10 +218,18 @@ func main() {
return
}
+ daemonPort := "8081" // Saxon 12 — XSLT 3.0 (default)
+ switch req.Version {
+ case "1.0":
+ daemonPort = "8082" // XSLTC (JDK) — true XSLT 1.0
+ case "2.0":
+ daemonPort = "8083" // Saxon 9 — true XSLT 2.0
+ }
+
start := time.Now()
httpClient := &http.Client{Timeout: 10 * time.Second}
resp, err := httpClient.Post(
- "http://127.0.0.1:8081/transform",
+ "http://127.0.0.1:"+daemonPort+"/transform",
"application/json",
bytes.NewReader(daemonBody),
)
@@ -250,7 +258,7 @@ func main() {
}
if daemonResp.Error != "" {
- log.Printf("saxon error after %dms: %s", duration, daemonResp.Error)
+ log.Printf("transform error after %dms: %s", duration, daemonResp.Error)
c.JSON(http.StatusBadRequest, gin.H{"error": daemonResp.Error})
return
}
diff --git a/backend/start.sh b/backend/start.sh
index 57adfe9f..15d71262 100644
--- a/backend/start.sh
+++ b/backend/start.sh
@@ -1,27 +1,47 @@
#!/bin/sh
set -e
-# Start Saxon daemon in background
+# ── Saxon 12 — XSLT 3.0 (port 8081) ─────────────────────────────────────────
java \
-Xms64m -Xmx256m \
-XX:+UseSerialGC \
- -cp '/opt/saxon/*' \
+ -cp '/opt/saxon12/*' \
com.xsltplayground.SaxonDaemon &
-DAEMON_PID=$!
+# ── XSLTC / JDK — XSLT 1.0 (port 8082) ──────────────────────────────────────
+java \
+ -Xms32m -Xmx128m \
+ -XX:+UseSerialGC \
+ -cp '/opt/xalan/*' \
+ com.xsltplayground.XalanDaemon &
-# Wait until the daemon responds on /health (max 30s)
-echo "Waiting for SaxonDaemon..."
-TRIES=0
-until wget -qO- http://127.0.0.1:8081/health > /dev/null 2>&1; do
- TRIES=$((TRIES + 1))
- if [ $TRIES -ge 60 ]; then
- echo "SaxonDaemon did not start in time" >&2
- exit 1
- fi
- sleep 0.5
-done
-echo "SaxonDaemon ready after ${TRIES} probes."
+# ── Saxon 9 — XSLT 2.0 (port 8083) ──────────────────────────────────────────
+java \
+ -Xms32m -Xmx128m \
+ -XX:+UseSerialGC \
+ -cp '/opt/saxon9/*' \
+ com.xsltplayground.Saxon2Daemon &
-# Start Go server in foreground
+# ── Wait for all three daemons ────────────────────────────────────────────────
+wait_for() {
+ PORT=$1
+ NAME=$2
+ echo "Waiting for $NAME on :$PORT..."
+ TRIES=0
+ until wget -qO- "http://127.0.0.1:$PORT/health" > /dev/null 2>&1; do
+ TRIES=$((TRIES + 1))
+ if [ $TRIES -ge 60 ]; then
+ echo "$NAME did not start in time" >&2
+ exit 1
+ fi
+ sleep 0.5
+ done
+ echo "$NAME ready after ${TRIES} probes."
+}
+
+wait_for 8081 SaxonDaemon
+wait_for 8082 XalanDaemon
+wait_for 8083 Saxon2Daemon
+
+# ── Go server in foreground ───────────────────────────────────────────────────
exec ./server