Skip to content

Commit 7f1c5de

Browse files
SentryManrbygrave
andauthored
support range requests (#252)
Co-authored-by: Rob Bygrave <[email protected]>
1 parent b3aed51 commit 7f1c5de

File tree

14 files changed

+440
-6
lines changed

14 files changed

+440
-6
lines changed

avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticClassResourceHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ private void sendURL(Context ctx, String urlPath, URL path) {
9393
addCachedEntry(ctx, urlPath, fis);
9494
return;
9595
}
96-
ctx.write(fis);
96+
ctx.rangedWrite(fis);
9797
} catch (final Exception e) {
9898
throw404(ctx.exchange());
9999
}

avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticFileHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ private void sendFile(Context ctx, HttpExchange jdkExchange, String urlPath, Fil
108108
addCachedEntry(ctx, urlPath, fis);
109109
return;
110110
}
111-
ctx.write(fis);
111+
ctx.rangedWrite(fis);
112112
} catch (FileNotFoundException e) {
113113
throw404(jdkExchange);
114114
}

avaje-jex/src/main/java/io/avaje/jex/DJexConfig.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ final class DJexConfig implements JexConfig {
2828
private final CompressionConfig compression = new CompressionConfig();
2929
private int bufferInitial = 256;
3030
private long bufferMax = 4096L;
31+
private int rangeChunkSize = 131_072;
3132
private HttpServerProvider serverProvider;
3233

3334
@Override
@@ -199,4 +200,15 @@ public JexConfig serverProvider(HttpServerProvider serverProvider) {
199200
this.serverProvider = serverProvider;
200201
return this;
201202
}
203+
204+
@Override
205+
public int rangeChunkSize() {
206+
return rangeChunkSize;
207+
}
208+
209+
@Override
210+
public JexConfig rangeChunkSize(int rangeChunkSize) {
211+
this.rangeChunkSize = rangeChunkSize;
212+
return this;
213+
}
202214
}

avaje-jex/src/main/java/io/avaje/jex/JexConfig.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,17 @@ public interface JexConfig {
146146
*/
147147
JexConfig port(int port);
148148

149+
/** The configured rangeChunk size */
150+
int rangeChunkSize();
151+
152+
/**
153+
* Set the chunk size on range requests, set to a high number to reduce the amount of range
154+
* requests (especially for video streaming)
155+
*
156+
* @param rangeChunkSize chunk size on range requests
157+
*/
158+
JexConfig rangeChunkSize(int rangeChunkSize);
159+
149160
/**
150161
* Registers a template renderer for a specific file extension.
151162
*
@@ -185,5 +196,4 @@ public interface JexConfig {
185196
* default value is used
186197
*/
187198
JexConfig socketBacklog(int backlog);
188-
189199
}

avaje-jex/src/main/java/io/avaje/jex/compression/CompressedOutputStream.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ private void decideCompression(int length) throws IOException {
3636
if (!compressionDecided) {
3737
boolean compressionAllowed =
3838
compressedStream == null
39+
&& ctx.responseHeader(Constants.CONTENT_RANGE) == null
3940
&& compression.allowsForCompression(ctx.responseHeader(Constants.CONTENT_TYPE));
4041

4142
if (compressionAllowed && length >= minSizeForCompression) {

avaje-jex/src/main/java/io/avaje/jex/core/BufferedOutStream.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ final class BufferedOutStream extends OutputStream {
1818
this.context = context;
1919
this.max = max;
2020
this.buffer = new ByteArrayOutputStream(initial);
21+
22+
// if content length is set, skip buffer
23+
if (context.responseHeader(Constants.CONTENT_LENGTH) != null) {
24+
count = max + 1;
25+
}
2126
}
2227

2328
@Override
@@ -52,7 +57,9 @@ public void write(byte[] b, int off, int len) throws IOException {
5257
/** Use responseLength 0 and chunked response. */
5358
private void initialiseChunked() throws IOException {
5459
final HttpExchange exchange = context.exchange();
55-
exchange.sendResponseHeaders(context.statusCode(), 0);
60+
// if a manual content-length is set, honor that instead of chunking
61+
String length = context.responseHeader(Constants.CONTENT_LENGTH);
62+
exchange.sendResponseHeaders(context.statusCode(), length == null ? 0 : Long.parseLong(length));
5663
stream = exchange.getResponseBody();
5764
// empty the existing buffer
5865
buffer.writeTo(stream);

avaje-jex/src/main/java/io/avaje/jex/core/Constants.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,9 @@ private Constants() {}
1919
public static final String TEXT_PLAIN_UTF8 = "text/plain;charset=utf-8";
2020
public static final String APPLICATION_JSON = "application/json";
2121
public static final String APPLICATION_X_JSON_STREAM = "application/x-json-stream";
22+
23+
// range
24+
public static final String ACCEPT_RANGES = "Accept-ranges";
25+
public static final String RANGE = "Range";
26+
public static final String CONTENT_RANGE = "Content-range";
2227
}

avaje-jex/src/main/java/io/avaje/jex/core/JdkContext.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,4 +536,9 @@ public void write(String content) {
536536
public JsonService jsonService() {
537537
return mgr.jsonService();
538538
}
539+
540+
@Override
541+
public void rangedWrite(InputStream inputStream, long totalBytes) {
542+
mgr.writeRange(this, inputStream, totalBytes);
543+
}
539544
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package io.avaje.jex.core;
2+
3+
import java.io.IOException;
4+
import java.io.InputStream;
5+
import java.io.OutputStream;
6+
import java.io.UncheckedIOException;
7+
import java.util.Arrays;
8+
import java.util.List;
9+
10+
import io.avaje.jex.http.Context;
11+
import io.avaje.jex.http.HttpStatus;
12+
13+
class RangeWriter {
14+
15+
private static final int DEFAULT_BUFFER_SIZE = 16384;
16+
17+
static void write(Context ctx, InputStream inputStream, long totalBytes, long chunkSize) {
18+
19+
ctx.header(Constants.ACCEPT_RANGES, "bytes");
20+
final String rangeHeader = ctx.header(Constants.RANGE);
21+
if (rangeHeader == null) {
22+
ctx.contentLength(totalBytes).write(inputStream);
23+
return;
24+
}
25+
final List<String> requestedRange =
26+
Arrays.stream(rangeHeader.split("=")[1].split("-")).filter(s -> !s.isEmpty()).toList();
27+
final long from = Long.parseLong(requestedRange.get(0));
28+
final long to;
29+
30+
final boolean audioOrVideo = isAudioOrVideo(ctx.responseHeader(Constants.CONTENT_TYPE));
31+
32+
if (!audioOrVideo || from + chunkSize > totalBytes) {
33+
to = totalBytes - 1; // chunk bigger than file, write all
34+
} else if (requestedRange.size() == 2) {
35+
to = Long.parseLong(requestedRange.get(1)); // chunk smaller than file, to/from specified
36+
} else {
37+
to = from + chunkSize - 1;
38+
}
39+
40+
long contentLength;
41+
if (audioOrVideo) {
42+
contentLength = Math.min(to - from + 1, totalBytes);
43+
} else {
44+
contentLength = totalBytes - from;
45+
}
46+
47+
final HttpStatus status;
48+
if (audioOrVideo) {
49+
status = HttpStatus.PARTIAL_CONTENT_206;
50+
} else {
51+
status = HttpStatus.OK_200;
52+
}
53+
54+
ctx.status(status);
55+
ctx.header(Constants.ACCEPT_RANGES, "bytes");
56+
ctx.header(Constants.CONTENT_RANGE, "bytes " + from + "-" + to + "/" + totalBytes);
57+
ctx.contentLength(contentLength);
58+
try (var os = ctx.outputStream()) {
59+
write(os, inputStream, from, to);
60+
} catch (IOException e) {
61+
throw new UncheckedIOException(e);
62+
}
63+
}
64+
65+
private static void write(OutputStream outputStream, InputStream inputStream, long from, long to)
66+
throws IOException {
67+
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
68+
long toSkip = from;
69+
while (toSkip > 0) {
70+
toSkip -= inputStream.skip(toSkip);
71+
}
72+
long bytesLeft = to - from + 1;
73+
while (bytesLeft > 0) {
74+
int read = inputStream.read(buffer, 0, (int) Math.min(DEFAULT_BUFFER_SIZE, bytesLeft));
75+
if (read == -1) {
76+
break; // End of stream reached unexpectedly
77+
}
78+
outputStream.write(buffer, 0, read);
79+
bytesLeft -= read;
80+
}
81+
}
82+
83+
private static boolean isAudioOrVideo(String contentType) {
84+
return contentType.startsWith("audio/") || contentType.startsWith("video/");
85+
}
86+
}

avaje-jex/src/main/java/io/avaje/jex/core/ServiceManager.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ final class ServiceManager {
3737
private final String scheme;
3838
private final int bufferInitial;
3939
private final long bufferMax;
40+
private final int rangeChunks;
4041

4142
static ServiceManager create(Jex jex) {
4243
return new Builder(jex).build();
@@ -49,14 +50,16 @@ static ServiceManager create(Jex jex) {
4950
TemplateManager templateManager,
5051
String scheme,
5152
long bufferMax,
52-
int bufferInitial) {
53+
int bufferInitial,
54+
int rangeChunks) {
5355
this.compressionConfig = compressionConfig;
5456
this.jsonService = jsonService;
5557
this.exceptionHandler = manager;
5658
this.templateManager = templateManager;
5759
this.scheme = scheme;
5860
this.bufferInitial = bufferInitial;
5961
this.bufferMax = bufferMax;
62+
this.rangeChunks = rangeChunks;
6063
}
6164

6265
OutputStream createOutputStream(JdkContext jdkContext) {
@@ -97,6 +100,10 @@ <E> void toJsonStream(Iterator<E> iterator, OutputStream os) {
97100
}
98101
}
99102

103+
void writeRange(Context ctx, InputStream is, long totalBytes) {
104+
RangeWriter.write(ctx, is, totalBytes, rangeChunks);
105+
}
106+
100107
void maybeClose(Object iterator) {
101108
if (iterator instanceof AutoCloseable closeable) {
102109
try {
@@ -177,7 +184,8 @@ ServiceManager build() {
177184
initTemplateMgr(),
178185
jex.config().scheme(),
179186
jex.config().maxStreamBufferSize(),
180-
jex.config().initialStreamBufferSize());
187+
jex.config().initialStreamBufferSize(),
188+
jex.config().rangeChunkSize());
181189
}
182190

183191
JsonService initJsonService() {

0 commit comments

Comments
 (0)