revert: completely restore original StaticResource — no Range support
Build & Deploy / build-and-deploy (push) Successful in 40s
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:
@@ -2,7 +2,6 @@ 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;
|
||||
@@ -10,11 +9,8 @@ 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("/")
|
||||
@@ -23,48 +19,11 @@ 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,
|
||||
@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,
|
||||
@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();
|
||||
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();
|
||||
}
|
||||
@@ -73,72 +32,35 @@ public class StaticResource {
|
||||
}
|
||||
|
||||
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, chunked encoding)
|
||||
if (rangeHeader == null || rangeHeader.isBlank()) {
|
||||
Response.ResponseBuilder rb = Response.ok(new FileStream(file, 0, fileSize))
|
||||
.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();
|
||||
}
|
||||
if (end >= fileSize) {
|
||||
end = fileSize - 1;
|
||||
}
|
||||
|
||||
long contentLength = end - start + 1;
|
||||
Response.ResponseBuilder rb = Response.status(206)
|
||||
.entity(new FileStream(file, start, contentLength))
|
||||
return Response.ok(new FileStream(file))
|
||||
.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();
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Convenience: full-file 200 response (chunked encoding, no Content-Length). */
|
||||
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");
|
||||
if (cacheControl != null) {
|
||||
rb.header("Cache-Control", cacheControl);
|
||||
@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();
|
||||
}
|
||||
return rb.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();
|
||||
}
|
||||
|
||||
/** Health check */
|
||||
@GET
|
||||
@jakarta.ws.rs.Path("/api/health")
|
||||
public Response health() {
|
||||
return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
|
||||
}
|
||||
|
||||
private String guessContentType(String name) {
|
||||
@@ -157,31 +79,11 @@ public class StaticResource {
|
||||
return MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming file output that avoids loading the whole file into memory.
|
||||
* 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 {
|
||||
/** Streaming file output that avoids loading the whole file into memory. */
|
||||
private record FileStream(Path file) implements StreamingOutput {
|
||||
@Override
|
||||
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);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Files.copy(file, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user