package com.example.space; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.InetSocketAddress; import java.net.Socket; import java.net.URL; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; public final class SpaceProxy { private static final int DEFAULT_PORT = 7860; private static final int BACKEND_PORT = 8080; private static final String WEB_ROOT = "/app/web"; private static final String BACKEND_CLASSPATH = "/app/backend/classes:/app/backend/lib/*"; public static void main(String[] args) throws Exception { Process backendProcess = startBackend(); Runtime.getRuntime().addShutdownHook(new Thread(() -> { if (backendProcess.isAlive()) { backendProcess.destroy(); } })); waitForBackend(backendProcess); int port = readPort(); HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0); server.createContext("/", exchange -> { String path = normalizePath(exchange.getRequestURI().getRawPath()); if (path.startsWith("/api/")) { proxyToBackend(exchange); return; } serveStatic(exchange, path); }); server.setExecutor(Executors.newCachedThreadPool()); server.start(); System.out.println("Space proxy started at http://0.0.0.0:" + port); backendProcess.onExit().thenRun(() -> { System.err.println("Backend process exited unexpectedly."); server.stop(0); System.exit(1); }); } private static Process startBackend() throws IOException { ProcessBuilder builder = new ProcessBuilder( "java", "-cp", BACKEND_CLASSPATH, "com.example.dorm.SimpleHttpServer" ); builder.directory(new File("/app")); builder.inheritIO(); return builder.start(); } private static void waitForBackend(Process backendProcess) throws InterruptedException, IOException { long deadline = System.currentTimeMillis() + 30000; while (System.currentTimeMillis() < deadline) { if (!backendProcess.isAlive()) { throw new IllegalStateException("Backend process exited before it became ready."); } try (Socket socket = new Socket()) { socket.connect(new InetSocketAddress("127.0.0.1", BACKEND_PORT), 500); return; } catch (IOException ignored) { Thread.sleep(250); } } throw new IllegalStateException("Timed out waiting for backend to start."); } private static int readPort() { String portValue = System.getenv("PORT"); if (portValue == null || portValue.trim().isEmpty()) { return DEFAULT_PORT; } try { return Integer.parseInt(portValue.trim()); } catch (NumberFormatException ex) { return DEFAULT_PORT; } } private static String normalizePath(String rawPath) { if (rawPath == null || rawPath.isEmpty()) { return "/"; } try { return URLDecoder.decode(rawPath, StandardCharsets.UTF_8.name()); } catch (Exception ex) { return rawPath; } } private static void serveStatic(HttpExchange exchange, String path) throws IOException { if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) { exchange.sendResponseHeaders(204, -1); exchange.close(); return; } if (path.equals("/") || path.isEmpty()) { path = "/index.html"; } if (path.endsWith("/")) { path = path + "index.html"; } Path basePath = Paths.get(WEB_ROOT).toAbsolutePath().normalize(); String relativePath = path.startsWith("/") ? path.substring(1) : path; Path candidate = basePath.resolve(relativePath).normalize(); if (!candidate.startsWith(basePath) || !Files.isRegularFile(candidate)) { sendText(exchange, 404, "Not Found"); return; } String fileName = candidate.getFileName().toString(); byte[] bytes; String contentType; if ("index.html".equalsIgnoreCase(fileName)) { String html = Files.readString(candidate, StandardCharsets.UTF_8); html = injectFetchPatch(html); bytes = html.getBytes(StandardCharsets.UTF_8); contentType = "text/html; charset=UTF-8"; } else { bytes = Files.readAllBytes(candidate); contentType = guessContentType(fileName); } exchange.getResponseHeaders().add("Content-Type", contentType); exchange.sendResponseHeaders(200, bytes.length); try (OutputStream output = exchange.getResponseBody()) { output.write(bytes); } } private static String injectFetchPatch(String html) { String patch = ""; int headIndex = html.indexOf(""); if (headIndex < 0) { return patch + html; } return html.substring(0, headIndex) + patch + html.substring(headIndex); } private static void proxyToBackend(HttpExchange exchange) throws IOException { String path = exchange.getRequestURI().getRawPath(); String query = exchange.getRequestURI().getRawQuery(); String target = "http://127.0.0.1:" + BACKEND_PORT + path + (query == null ? "" : "?" + query); HttpURLConnection connection = (HttpURLConnection) new URL(target).openConnection(); connection.setInstanceFollowRedirects(false); connection.setRequestMethod(exchange.getRequestMethod()); connection.setConnectTimeout(5000); connection.setReadTimeout(30000); connection.setDoInput(true); copyRequestHeaders(exchange, connection); byte[] requestBody = readAllBytes(exchange.getRequestBody()); if (requestBody.length > 0 && allowsRequestBody(exchange.getRequestMethod())) { connection.setDoOutput(true); try (OutputStream output = connection.getOutputStream()) { output.write(requestBody); } } int status = connection.getResponseCode(); byte[] responseBody = readAllBytes(status >= 400 ? connection.getErrorStream() : connection.getInputStream()); Headers responseHeaders = exchange.getResponseHeaders(); copyResponseHeaders(connection, responseHeaders); long responseLength = responseBody == null ? 0 : responseBody.length; if (status == 204 || status == 304) { responseLength = -1; } exchange.sendResponseHeaders(status, responseLength); if (responseLength != -1) { try (OutputStream output = exchange.getResponseBody()) { if (responseBody != null) { output.write(responseBody); } } } else { exchange.getResponseBody().close(); } } private static void copyRequestHeaders(HttpExchange exchange, HttpURLConnection connection) { for (Map.Entry> entry : exchange.getRequestHeaders().entrySet()) { String name = entry.getKey(); if (name == null || isHopByHopHeader(name) || "Host".equalsIgnoreCase(name)) { continue; } List values = entry.getValue(); if (values == null || values.isEmpty()) { continue; } connection.setRequestProperty(name, String.join(",", values)); } } private static void copyResponseHeaders(HttpURLConnection connection, Headers responseHeaders) { for (Map.Entry> entry : connection.getHeaderFields().entrySet()) { String name = entry.getKey(); if (name == null || isHopByHopHeader(name) || "Content-Length".equalsIgnoreCase(name)) { continue; } List values = entry.getValue(); if (values == null) { continue; } for (String value : values) { if (value != null) { responseHeaders.add(name, value); } } } } private static boolean allowsRequestBody(String method) { return Arrays.asList("POST", "PUT", "PATCH", "DELETE").contains(method.toUpperCase()); } private static boolean isHopByHopHeader(String name) { return "Connection".equalsIgnoreCase(name) || "Keep-Alive".equalsIgnoreCase(name) || "Proxy-Authenticate".equalsIgnoreCase(name) || "Proxy-Authorization".equalsIgnoreCase(name) || "TE".equalsIgnoreCase(name) || "Trailer".equalsIgnoreCase(name) || "Transfer-Encoding".equalsIgnoreCase(name) || "Upgrade".equalsIgnoreCase(name) || "Content-Length".equalsIgnoreCase(name); } private static byte[] readAllBytes(InputStream inputStream) throws IOException { if (inputStream == null) { return new byte[0]; } try (InputStream in = inputStream; ByteArrayOutputStream output = new ByteArrayOutputStream()) { byte[] buffer = new byte[8192]; int read; while ((read = in.read(buffer)) != -1) { output.write(buffer, 0, read); } return output.toByteArray(); } } private static void sendText(HttpExchange exchange, int status, String text) throws IOException { byte[] bytes = text.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=UTF-8"); exchange.sendResponseHeaders(status, bytes.length); try (OutputStream output = exchange.getResponseBody()) { output.write(bytes); } } private static String guessContentType(String fileName) { String lowerName = fileName.toLowerCase(); if (lowerName.endsWith(".html") || lowerName.endsWith(".htm")) return "text/html; charset=UTF-8"; if (lowerName.endsWith(".js")) return "application/javascript; charset=UTF-8"; if (lowerName.endsWith(".css")) return "text/css; charset=UTF-8"; if (lowerName.endsWith(".json")) return "application/json; charset=UTF-8"; if (lowerName.endsWith(".txt")) return "text/plain; charset=UTF-8"; if (lowerName.endsWith(".svg")) return "image/svg+xml"; if (lowerName.endsWith(".png")) return "image/png"; if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) return "image/jpeg"; if (lowerName.endsWith(".gif")) return "image/gif"; if (lowerName.endsWith(".webp")) return "image/webp"; if (lowerName.endsWith(".ico")) return "image/x-icon"; if (lowerName.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; return "application/octet-stream"; } }