diff --git a/Dockerfile b/Dockerfile index 8d49537..66f6cf1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,4 +25,7 @@ COPY --from=build /build/target/quarkus-app/ /app/ EXPOSE 8765 VOLUME /data ENV DOSTALGIA_DATA_DIR=/data -ENTRYPOINT ["java", "-jar", "quarkus-run.jar"] +# Limit JVM heap — the app streams everything (upload/serve) so 128MB is plenty +# G1GC with periodic GC allows heap to shrink when idle +ENTRYPOINT ["java", "-Xmx128m", "-Xms64m", "-XX:+UseG1GC", "-XX:G1PeriodicGCInterval=30000", + "-jar", "quarkus-run.jar"] diff --git a/src/main/java/com/dostalgia/StaticResource.java b/src/main/java/com/dostalgia/StaticResource.java index 7944b47..b2b8600 100644 --- a/src/main/java/com/dostalgia/StaticResource.java +++ b/src/main/java/com/dostalgia/StaticResource.java @@ -2,6 +2,7 @@ package com.dostalgia; import jakarta.inject.Inject; import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -9,8 +10,11 @@ import jakarta.ws.rs.core.StreamingOutput; import org.eclipse.microprofile.config.inject.ConfigProperty; import java.io.*; +import java.nio.channels.FileChannel; import java.nio.file.*; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** Serves game bundles (.jsdos) and artwork from the external data directory. */ @jakarta.ws.rs.Path("/") @@ -19,42 +23,28 @@ public class StaticResource { @ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data") String dataDir; + /** Pattern for HTTP Range header: bytes=start-end */ + private static final Pattern RANGE_PATTERN = Pattern.compile( + "^bytes=(\\d+)-(\\d*)$", Pattern.CASE_INSENSITIVE + ); + @GET @jakarta.ws.rs.Path("/games/{path: .*}") - public Response getGameFile(@PathParam("path") String path) { - Path file = Path.of(dataDir, "games", path).normalize(); - Path base = Path.of(dataDir, "games").normalize(); - if (!file.startsWith(base)) { - return Response.status(403).build(); - } - if (!Files.exists(file) || Files.isDirectory(file)) { - return Response.status(404).build(); - } - - String contentType = guessContentType(file.getFileName().toString()); - return Response.ok(new FileStream(file)) - .type(contentType) - .header("Accept-Ranges", "bytes") - .build(); + public Response getGameFile( + @PathParam("path") String path, + @HeaderParam("Range") String rangeHeader + ) { + return serveFile(Path.of(dataDir, "games", path), rangeHeader, null); } @GET @jakarta.ws.rs.Path("/artwork/{path: .*}") - public Response getArtwork(@PathParam("path") String path) { - Path file = Path.of(dataDir, "artwork", path).normalize(); - Path base = Path.of(dataDir, "artwork").normalize(); - if (!file.startsWith(base)) { - return Response.status(403).build(); - } - if (!Files.exists(file) || Files.isDirectory(file)) { - return Response.status(404).build(); - } - - String contentType = guessContentType(file.getFileName().toString()); - return Response.ok(new FileStream(file)) - .type(contentType) - .header("Cache-Control", "public, max-age=86400") - .build(); + public Response getArtwork( + @PathParam("path") String path, + @HeaderParam("Range") String rangeHeader + ) { + return serveFile(Path.of(dataDir, "artwork", path), rangeHeader, + "public, max-age=86400"); } /** Health check */ @@ -64,6 +54,95 @@ public class StaticResource { return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build(); } + /** + * Serve a file with optional HTTP Range support. + *
+ * js-dos v7 uses Range requests to lazily load ZIP entries from .jsdos + * bundles. Without proper Range handling, the entire multi-GB file gets + * streamed to the browser and cached in the OS page cache, keeping memory + * high long after play ends. + */ + private Response serveFile(Path file, String rangeHeader, String cacheControl) { + Path base = file.getParent().normalize(); + file = file.normalize(); + if (!file.startsWith(base)) { + return Response.status(403).build(); + } + if (!Files.exists(file) || Files.isDirectory(file)) { + return Response.status(404).build(); + } + + String contentType = guessContentType(file.getFileName().toString()); + long fileSize; + try { + fileSize = Files.size(file); + } catch (IOException e) { + return Response.serverError().build(); + } + + // No Range header — serve the full file (200 OK) + if (rangeHeader == null || rangeHeader.isBlank()) { + Response.ResponseBuilder rb = Response.ok(new FileStream(file, 0, fileSize)) + .type(contentType) + .header("Accept-Ranges", "bytes") + .header("Content-Length", fileSize); + if (cacheControl != null) { + rb.header("Cache-Control", cacheControl); + } + return rb.build(); + } + + // Parse Range header: bytes=start-end + Matcher m = RANGE_PATTERN.matcher(rangeHeader.trim()); + if (!m.matches()) { + // Unrecognised range format — serve full file + return serveFull(file, contentType, fileSize, cacheControl); + } + + long start = Long.parseLong(m.group(1)); + long end; + if (m.group(2).isEmpty()) { + // Open-ended: bytes=N- → serve from N to end + end = fileSize - 1; + } else { + end = Long.parseLong(m.group(2)); + } + + // Validate + if (start >= fileSize || end < start) { + return Response.status(416) + .header("Content-Range", "bytes */" + fileSize) + .build(); + } + if (end >= fileSize) { + end = fileSize - 1; + } + + long contentLength = end - start + 1; + Response.ResponseBuilder rb = Response.status(206) + .entity(new FileStream(file, start, contentLength)) + .type(contentType) + .header("Accept-Ranges", "bytes") + .header("Content-Range", "bytes " + start + "-" + end + "/" + fileSize) + .header("Content-Length", contentLength); + if (cacheControl != null) { + rb.header("Cache-Control", cacheControl); + } + return rb.build(); + } + + /** Convenience: full-file 200 response. */ + private Response serveFull(Path file, String contentType, long fileSize, String cacheControl) { + Response.ResponseBuilder rb = Response.ok(new FileStream(file, 0, fileSize)) + .type(contentType) + .header("Accept-Ranges", "bytes") + .header("Content-Length", fileSize); + if (cacheControl != null) { + rb.header("Cache-Control", cacheControl); + } + return rb.build(); + } + private String guessContentType(String name) { String n = name.toLowerCase(); if (n.endsWith(".jsdos")) return "application/zip"; @@ -80,11 +159,28 @@ public class StaticResource { return MediaType.APPLICATION_OCTET_STREAM; } - /** Streaming file output that avoids loading the whole file into memory. */ - private record FileStream(Path file) implements StreamingOutput { + /** + * Streaming file output that avoids loading the whole file into memory. + * Supports partial reads (seek + limit) for HTTP Range requests. + */ + private record FileStream(Path file, long offset, long length) implements StreamingOutput { @Override public void write(OutputStream output) throws IOException { - Files.copy(file, output); + try (var raf = new RandomAccessFile(file.toFile(), "r"); + var channel = raf.getChannel()) { + if (offset > 0) { + raf.seek(offset); + } + long remaining = length; + byte[] buf = new byte[8192]; + while (remaining > 0) { + int toRead = (int) Math.min(buf.length, remaining); + int read = raf.read(buf, 0, toRead); + if (read < 0) break; + output.write(buf, 0, read); + remaining -= read; + } + } } } }