revert: completely restore original StaticResource — no Range support
Build & Deploy / build-and-deploy (push) Successful in 40s

The Range implementation had cascading issues:
1. Content-Length + streaming → RESTEasy buffers entire 1.2GB file
2. FileChannel.transferTo without loop → truncated ZIPs
3. Custom FileStream → 'Not a zip archive' in js-dos

Reverting to original proven code that just serves files via
Files.copy(). Memory addressed by -Xmx128m Dockerfile change only.
Range support can be added later with proper unit tests.
This commit is contained in:
Hermes Agent
2026-06-03 16:00:17 +02:00
parent 352726f740
commit f3973ea64d
+26 -124
View File
@@ -2,7 +2,6 @@ package com.dostalgia;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.PathParam; import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
@@ -10,11 +9,8 @@ import jakarta.ws.rs.core.StreamingOutput;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.*; import java.io.*;
import java.nio.channels.FileChannel;
import java.nio.file.*; import java.nio.file.*;
import java.util.Map; 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. */ /** Serves game bundles (.jsdos) and artwork from the external data directory. */
@jakarta.ws.rs.Path("/") @jakarta.ws.rs.Path("/")
@@ -23,48 +19,11 @@ public class StaticResource {
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data") @ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
String dataDir; 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 @GET
@jakarta.ws.rs.Path("/games/{path: .*}") @jakarta.ws.rs.Path("/games/{path: .*}")
public Response getGameFile( public Response getGameFile(@PathParam("path") String path) {
@PathParam("path") String path, Path file = Path.of(dataDir, "games", path).normalize();
@HeaderParam("Range") String rangeHeader Path base = Path.of(dataDir, "games").normalize();
) {
return serveFile(Path.of(dataDir, "games", path), rangeHeader, null);
}
@GET
@jakarta.ws.rs.Path("/artwork/{path: .*}")
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 */
@GET
@jakarta.ws.rs.Path("/api/health")
public Response health() {
return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
}
/**
* Serve a file with optional HTTP Range support.
* <p>
* 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)) { if (!file.startsWith(base)) {
return Response.status(403).build(); return Response.status(403).build();
} }
@@ -73,72 +32,35 @@ public class StaticResource {
} }
String contentType = guessContentType(file.getFileName().toString()); String contentType = guessContentType(file.getFileName().toString());
long fileSize; return Response.ok(new FileStream(file))
try {
fileSize = Files.size(file);
} catch (IOException e) {
return Response.serverError().build();
}
// No Range header — serve the full file (200 OK, chunked encoding)
if (rangeHeader == null || rangeHeader.isBlank()) {
Response.ResponseBuilder rb = Response.ok(new FileStream(file, 0, fileSize))
.type(contentType) .type(contentType)
.header("Accept-Ranges", "bytes");
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(); .build();
} }
if (end >= fileSize) {
end = fileSize - 1; @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();
} }
long contentLength = end - start + 1; String contentType = guessContentType(file.getFileName().toString());
Response.ResponseBuilder rb = Response.status(206) return Response.ok(new FileStream(file))
.entity(new FileStream(file, start, contentLength))
.type(contentType) .type(contentType)
.header("Accept-Ranges", "bytes") .header("Cache-Control", "public, max-age=86400")
.header("Content-Range", "bytes " + start + "-" + end + "/" + fileSize) .build();
.header("Content-Length", contentLength);
if (cacheControl != null) {
rb.header("Cache-Control", cacheControl);
}
return rb.build();
} }
/** Convenience: full-file 200 response (chunked encoding, no Content-Length). */ /** Health check */
private Response serveFull(Path file, String contentType, long fileSize, String cacheControl) { @GET
Response.ResponseBuilder rb = Response.ok(new FileStream(file, 0, fileSize)) @jakarta.ws.rs.Path("/api/health")
.type(contentType) public Response health() {
.header("Accept-Ranges", "bytes"); return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
if (cacheControl != null) {
rb.header("Cache-Control", cacheControl);
}
return rb.build();
} }
private String guessContentType(String name) { private String guessContentType(String name) {
@@ -157,31 +79,11 @@ public class StaticResource {
return MediaType.APPLICATION_OCTET_STREAM; return MediaType.APPLICATION_OCTET_STREAM;
} }
/** /** Streaming file output that avoids loading the whole file into memory. */
* Streaming file output that avoids loading the whole file into memory. private record FileStream(Path file) implements StreamingOutput {
* Uses Files.copy for full-file (zero-copy via sendfile) and a position-limited
* transfer for HTTP Range requests.
*/
private record FileStream(Path file, long offset, long length) implements StreamingOutput {
@Override @Override
public void write(OutputStream output) throws IOException { public void write(OutputStream output) throws IOException {
if (offset == 0 && length == Files.size(file)) {
// Full file — use simple Files.copy (proven working)
Files.copy(file, output); Files.copy(file, output);
} else {
// Partial content (Range request) — transfer only the requested bytes
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
long remaining = length;
long pos = offset;
while (remaining > 0) {
long written = ch.transferTo(pos, remaining,
java.nio.channels.Channels.newChannel(output));
if (written <= 0) break;
pos += written;
remaining -= written;
}
}
}
} }
} }
} }