mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
Compare commits
1 Commits
bun-v1.3.5
...
claude/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
535fabc46e |
@@ -544,6 +544,7 @@ src/HTMLScanner.zig
|
||||
src/http.zig
|
||||
src/http/AsyncHTTP.zig
|
||||
src/http/CertificateInfo.zig
|
||||
src/http/ContentRange.zig
|
||||
src/http/Decompressor.zig
|
||||
src/http/Encoding.zig
|
||||
src/http/ETag.zig
|
||||
|
||||
@@ -219,6 +219,14 @@ pub fn onGET(this: *StaticRoute, req: *uws.Request, resp: AnyResponse) void {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Range requests for GET with 200 status
|
||||
if (this.status_code == 200) {
|
||||
if (req.header("range")) |range_header| {
|
||||
this.handleRangeRequest(req, resp, range_header);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with normal GET request handling
|
||||
req.setYield(false);
|
||||
this.on(resp);
|
||||
@@ -336,6 +344,12 @@ fn renderMetadata(this: *StaticRoute, resp: AnyResponse) void {
|
||||
status;
|
||||
|
||||
this.doWriteStatus(status, resp);
|
||||
|
||||
// Add Accept-Ranges header for 200 responses to indicate range support
|
||||
if (status == 200) {
|
||||
resp.writeHeader("Accept-Ranges", "bytes");
|
||||
}
|
||||
|
||||
this.doWriteHeaders(resp);
|
||||
}
|
||||
|
||||
@@ -375,6 +389,217 @@ fn render304NotModifiedIfNoneMatch(this: *StaticRoute, req: *uws.Request, resp:
|
||||
return true;
|
||||
}
|
||||
|
||||
fn handleRangeRequest(this: *StaticRoute, req: *uws.Request, resp: AnyResponse, range_header: []const u8) void {
|
||||
const content_size = this.cached_blob_size;
|
||||
|
||||
// Parse range requests
|
||||
const ranges = ContentRange.parseRangeHeader(range_header, bun.default_allocator) catch {
|
||||
// Invalid range header, serve full content
|
||||
req.setYield(false);
|
||||
this.on(resp);
|
||||
return;
|
||||
};
|
||||
defer bun.default_allocator.free(ranges);
|
||||
|
||||
// Filter valid ranges
|
||||
const valid_ranges = ContentRange.filterValidRanges(ranges, content_size, bun.default_allocator) catch {
|
||||
// Memory allocation error, serve full content
|
||||
req.setYield(false);
|
||||
this.on(resp);
|
||||
return;
|
||||
};
|
||||
defer bun.default_allocator.free(valid_ranges);
|
||||
|
||||
// If no valid ranges, return 416 Range Not Satisfiable
|
||||
if (valid_ranges.len == 0) {
|
||||
this.sendRangeNotSatisfiable(resp, content_size);
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, only handle single ranges (multipart ranges would need more complex implementation)
|
||||
if (valid_ranges.len > 1) {
|
||||
// Fall back to serving full content for multipart ranges
|
||||
req.setYield(false);
|
||||
this.on(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = valid_ranges[0];
|
||||
|
||||
// Check if this is actually a full content request
|
||||
if (range.start == 0 and range.actualEnd(content_size) == content_size - 1) {
|
||||
req.setYield(false);
|
||||
this.on(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
req.setYield(false);
|
||||
this.sendPartialContent(resp, range, content_size);
|
||||
}
|
||||
|
||||
fn sendRangeNotSatisfiable(this: *StaticRoute, resp: AnyResponse, content_size: u64) void {
|
||||
this.ref();
|
||||
if (this.server) |server| {
|
||||
server.onPendingRequest();
|
||||
resp.timeout(server.config().idleTimeout);
|
||||
}
|
||||
|
||||
this.doWriteStatus(416, resp);
|
||||
|
||||
// Add Content-Range header for unsatisfiable range
|
||||
const content_range_header = ContentRange.formatUnsatisfiableRangeHeader(content_size, bun.default_allocator) catch {
|
||||
// Fallback without Content-Range header
|
||||
this.doWriteHeaders(resp);
|
||||
resp.endWithoutBody(resp.shouldCloseConnection());
|
||||
this.onResponseComplete(resp);
|
||||
return;
|
||||
};
|
||||
defer bun.default_allocator.free(content_range_header);
|
||||
|
||||
resp.writeHeader("Content-Range", content_range_header);
|
||||
this.doWriteHeaders(resp);
|
||||
resp.endWithoutBody(resp.shouldCloseConnection());
|
||||
this.onResponseComplete(resp);
|
||||
}
|
||||
|
||||
fn sendPartialContent(this: *StaticRoute, resp: AnyResponse, range: ContentRange.Range, content_size: u64) void {
|
||||
this.ref();
|
||||
if (this.server) |server| {
|
||||
server.onPendingRequest();
|
||||
resp.timeout(server.config().idleTimeout);
|
||||
}
|
||||
|
||||
// Prepare headers for partial content
|
||||
var finished = false;
|
||||
this.doRenderPartialContent(resp, range, content_size, &finished);
|
||||
if (finished) {
|
||||
this.onResponseComplete(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
this.toAsyncPartial(resp, range, content_size);
|
||||
}
|
||||
|
||||
fn doRenderPartialContent(this: *StaticRoute, resp: AnyResponse, range: ContentRange.Range, content_size: u64, did_finish: *bool) void {
|
||||
const range_length = range.length(content_size);
|
||||
|
||||
// We are not corked
|
||||
// The range is small
|
||||
// Faster to do the memcpy than to do the two network calls
|
||||
if (range_length < 16384 - 1024) {
|
||||
resp.corked(doRenderPartialContentCorked, .{ this, resp, range, content_size, did_finish });
|
||||
} else {
|
||||
this.doRenderPartialContentCorked(resp, range, content_size, did_finish);
|
||||
}
|
||||
}
|
||||
|
||||
fn doRenderPartialContentCorked(this: *StaticRoute, resp: AnyResponse, range: ContentRange.Range, content_size: u64, did_finish: *bool) void {
|
||||
this.renderPartialMetadata(resp, range, content_size);
|
||||
this.renderPartialBytes(resp, range, content_size, did_finish);
|
||||
}
|
||||
|
||||
fn renderPartialMetadata(this: *StaticRoute, resp: AnyResponse, range: ContentRange.Range, content_size: u64) void {
|
||||
// Write 206 Partial Content status
|
||||
this.doWriteStatus(206, resp);
|
||||
|
||||
// Add Content-Range header
|
||||
const content_range_header = ContentRange.formatContentRangeHeader(range, content_size, bun.default_allocator) catch {
|
||||
// Fallback without Content-Range header
|
||||
this.doWriteHeaders(resp);
|
||||
return;
|
||||
};
|
||||
defer bun.default_allocator.free(content_range_header);
|
||||
|
||||
resp.writeHeader("Content-Range", content_range_header);
|
||||
|
||||
// Add Accept-Ranges header to indicate range support
|
||||
resp.writeHeader("Accept-Ranges", "bytes");
|
||||
|
||||
// Write original headers
|
||||
this.doWriteHeaders(resp);
|
||||
|
||||
// Override Content-Length with range length
|
||||
const range_length = range.length(content_size);
|
||||
resp.writeHeaderInt("Content-Length", range_length);
|
||||
}
|
||||
|
||||
fn renderPartialBytes(this: *StaticRoute, resp: AnyResponse, range: ContentRange.Range, content_size: u64, did_finish: *bool) void {
|
||||
_ = content_size;
|
||||
did_finish.* = this.onWritablePartialBytes(range, 0, resp);
|
||||
}
|
||||
|
||||
fn toAsyncPartial(this: *StaticRoute, resp: AnyResponse, range: ContentRange.Range, content_size: u64) void {
|
||||
_ = content_size;
|
||||
|
||||
const pending = bun.new(PendingRangeResponse, .{
|
||||
.range = range,
|
||||
.resp = resp,
|
||||
.route = this,
|
||||
});
|
||||
|
||||
this.ref(); // Keep the route alive while the response is pending
|
||||
|
||||
resp.onAborted(*PendingRangeResponse, PendingRangeResponse.onAborted, pending);
|
||||
resp.onWritable(*PendingRangeResponse, PendingRangeResponse.onWritable, pending);
|
||||
}
|
||||
|
||||
const PendingRangeResponse = struct {
|
||||
range: ContentRange.Range,
|
||||
resp: AnyResponse,
|
||||
route: *StaticRoute,
|
||||
is_response_pending: bool = true,
|
||||
|
||||
pub fn deinit(this: *PendingRangeResponse) void {
|
||||
if (this.is_response_pending) {
|
||||
this.resp.clearAborted();
|
||||
this.resp.clearOnWritable();
|
||||
}
|
||||
this.route.deref();
|
||||
bun.destroy(this);
|
||||
}
|
||||
|
||||
pub fn onAborted(this: *PendingRangeResponse, _: AnyResponse) void {
|
||||
bun.debugAssert(this.is_response_pending == true);
|
||||
this.is_response_pending = false;
|
||||
this.route.onResponseComplete(this.resp);
|
||||
this.deinit();
|
||||
}
|
||||
|
||||
pub fn onWritable(this: *PendingRangeResponse, write_offset: u64, resp: AnyResponse) bool {
|
||||
if (this.route.server) |server| {
|
||||
resp.timeout(server.config().idleTimeout);
|
||||
}
|
||||
|
||||
if (!this.route.onWritablePartialBytes(this.range, write_offset, resp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.is_response_pending = false;
|
||||
this.route.onResponseComplete(resp);
|
||||
this.deinit();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
fn onWritablePartialBytes(this: *StaticRoute, range: ContentRange.Range, write_offset: u64, resp: AnyResponse) bool {
|
||||
const blob = this.blob;
|
||||
const all_bytes = blob.slice();
|
||||
|
||||
// Calculate the actual slice for this range
|
||||
const range_start = @min(range.start, all_bytes.len);
|
||||
const range_end = @min(range.actualEnd(all_bytes.len), all_bytes.len - 1);
|
||||
|
||||
if (range_start > range_end or range_start >= all_bytes.len) {
|
||||
// Empty range
|
||||
return resp.tryEnd(&[_]u8{}, 0, resp.shouldCloseConnection());
|
||||
}
|
||||
|
||||
const range_bytes = all_bytes[range_start..range_end + 1];
|
||||
const bytes = range_bytes[@min(range_bytes.len, write_offset)..];
|
||||
|
||||
return resp.tryEnd(bytes, range_bytes.len, resp.shouldCloseConnection());
|
||||
}
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const bun = @import("bun");
|
||||
@@ -386,6 +611,7 @@ const AnyBlob = jsc.WebCore.Blob.Any;
|
||||
|
||||
const ETag = bun.http.ETag;
|
||||
const Headers = bun.http.Headers;
|
||||
const ContentRange = bun.http.ContentRange;
|
||||
|
||||
const uws = bun.uws;
|
||||
const AnyResponse = uws.AnyResponse;
|
||||
|
||||
@@ -2425,6 +2425,7 @@ pub const ThreadlocalAsyncHTTP = struct {
|
||||
};
|
||||
|
||||
pub const ETag = @import("./http/ETag.zig");
|
||||
pub const ContentRange = @import("./http/ContentRange.zig");
|
||||
pub const Method = @import("./http/Method.zig").Method;
|
||||
pub const Headers = @import("./http/Headers.zig");
|
||||
pub const MimeType = @import("./http/MimeType.zig");
|
||||
|
||||
320
src/http/ContentRange.zig
Normal file
320
src/http/ContentRange.zig
Normal file
@@ -0,0 +1,320 @@
|
||||
const ContentRange = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const bun = @import("bun");
|
||||
|
||||
/// Represents a single range request (e.g., "bytes=200-1000")
|
||||
pub const Range = struct {
|
||||
start: u64,
|
||||
end: ?u64, // null means "to the end"
|
||||
|
||||
pub fn length(self: Range, content_size: u64) u64 {
|
||||
const actual_end = self.end orelse (content_size - 1);
|
||||
return actual_end - self.start + 1;
|
||||
}
|
||||
|
||||
pub fn actualEnd(self: Range, content_size: u64) u64 {
|
||||
return self.end orelse (content_size - 1);
|
||||
}
|
||||
|
||||
pub fn isValid(self: Range, content_size: u64) bool {
|
||||
if (self.start >= content_size) return false;
|
||||
if (self.end) |end| {
|
||||
return end >= self.start and end < content_size;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/// Represents a suffix range request (e.g., "bytes=-500" for last 500 bytes)
|
||||
pub const SuffixRange = struct {
|
||||
suffix_length: u64,
|
||||
|
||||
pub fn toRange(self: SuffixRange, content_size: u64) Range {
|
||||
const start = if (content_size > self.suffix_length)
|
||||
content_size - self.suffix_length
|
||||
else
|
||||
0;
|
||||
return Range{
|
||||
.start = start,
|
||||
.end = content_size - 1,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Represents different types of range requests
|
||||
pub const RangeRequest = union(enum) {
|
||||
range: Range,
|
||||
suffix: SuffixRange,
|
||||
|
||||
pub fn toRange(self: RangeRequest, content_size: u64) Range {
|
||||
return switch (self) {
|
||||
.range => |r| r,
|
||||
.suffix => |s| s.toRange(content_size),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn isValid(self: RangeRequest, content_size: u64) bool {
|
||||
return switch (self) {
|
||||
.range => |r| r.isValid(content_size),
|
||||
.suffix => true, // Suffix ranges are always valid
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Parse the Range header value (e.g., "bytes=200-1000,2000-3000")
|
||||
pub fn parseRangeHeader(range_header: []const u8, allocator: std.mem.Allocator) ![]RangeRequest {
|
||||
var result = std.ArrayList(RangeRequest).init(allocator);
|
||||
errdefer result.deinit();
|
||||
|
||||
const trimmed = std.mem.trim(u8, range_header, " \t");
|
||||
|
||||
// Must start with "bytes="
|
||||
if (!bun.strings.hasPrefix(trimmed, "bytes=")) {
|
||||
return error.InvalidRangeHeader;
|
||||
}
|
||||
|
||||
const ranges_str = trimmed[6..]; // Skip "bytes="
|
||||
|
||||
var range_iter = std.mem.splitScalar(u8, ranges_str, ',');
|
||||
while (range_iter.next()) |range_str| {
|
||||
const range_trimmed = std.mem.trim(u8, range_str, " \t");
|
||||
if (range_trimmed.len == 0) continue;
|
||||
|
||||
const range_req = try parseRangeSpec(range_trimmed);
|
||||
try result.append(range_req);
|
||||
}
|
||||
|
||||
if (result.items.len == 0) {
|
||||
return error.InvalidRangeHeader;
|
||||
}
|
||||
|
||||
return try result.toOwnedSlice();
|
||||
}
|
||||
|
||||
/// Parse a single range specification (e.g., "200-1000", "200-", "-500")
|
||||
fn parseRangeSpec(range_spec: []const u8) !RangeRequest {
|
||||
const dash_pos = std.mem.indexOfScalar(u8, range_spec, '-') orelse return error.InvalidRangeSpec;
|
||||
|
||||
const start_str = range_spec[0..dash_pos];
|
||||
const end_str = range_spec[dash_pos + 1..];
|
||||
|
||||
// Suffix range: "-500"
|
||||
if (start_str.len == 0) {
|
||||
if (end_str.len == 0) return error.InvalidRangeSpec;
|
||||
const suffix_length = std.fmt.parseInt(u64, end_str, 10) catch return error.InvalidRangeSpec;
|
||||
return RangeRequest{ .suffix = SuffixRange{ .suffix_length = suffix_length } };
|
||||
}
|
||||
|
||||
// Parse start position
|
||||
const start = std.fmt.parseInt(u64, start_str, 10) catch return error.InvalidRangeSpec;
|
||||
|
||||
// Open-ended range: "200-"
|
||||
if (end_str.len == 0) {
|
||||
return RangeRequest{ .range = Range{ .start = start, .end = null } };
|
||||
}
|
||||
|
||||
// Closed range: "200-1000"
|
||||
const end = std.fmt.parseInt(u64, end_str, 10) catch return error.InvalidRangeSpec;
|
||||
if (end < start) return error.InvalidRangeSpec;
|
||||
|
||||
return RangeRequest{ .range = Range{ .start = start, .end = end } };
|
||||
}
|
||||
|
||||
/// Filter ranges to only include valid ones for the given content size
|
||||
pub fn filterValidRanges(ranges: []const RangeRequest, content_size: u64, allocator: std.mem.Allocator) ![]Range {
|
||||
var result = std.ArrayList(Range).init(allocator);
|
||||
errdefer result.deinit();
|
||||
|
||||
for (ranges) |range_req| {
|
||||
if (range_req.isValid(content_size)) {
|
||||
try result.append(range_req.toRange(content_size));
|
||||
}
|
||||
}
|
||||
|
||||
return try result.toOwnedSlice();
|
||||
}
|
||||
|
||||
/// Generate Content-Range header value for a single range
|
||||
pub fn formatContentRangeHeader(range: Range, content_size: u64, allocator: std.mem.Allocator) ![]u8 {
|
||||
const actual_end = range.actualEnd(content_size);
|
||||
return try std.fmt.allocPrint(allocator, "bytes {d}-{d}/{d}", .{ range.start, actual_end, content_size });
|
||||
}
|
||||
|
||||
/// Generate Content-Range header value for unsatisfiable range
|
||||
pub fn formatUnsatisfiableRangeHeader(content_size: u64, allocator: std.mem.Allocator) ![]u8 {
|
||||
return try std.fmt.allocPrint(allocator, "bytes */{d}", .{content_size});
|
||||
}
|
||||
|
||||
/// Check if the client accepts partial content based on request headers
|
||||
pub fn acceptsRanges(range_header: ?[]const u8) bool {
|
||||
return range_header != null;
|
||||
}
|
||||
|
||||
/// Determine the appropriate status code for a range request
|
||||
pub fn getRangeResponseStatus(ranges: []const Range, content_size: u64) u16 {
|
||||
if (ranges.len == 0) return 416; // Range Not Satisfiable
|
||||
if (ranges.len == 1) {
|
||||
const range = ranges[0];
|
||||
if (range.start == 0 and range.actualEnd(content_size) == content_size - 1) {
|
||||
return 200; // Full content
|
||||
}
|
||||
return 206; // Partial Content
|
||||
}
|
||||
return 206; // Multipart ranges (not yet implemented)
|
||||
}
|
||||
|
||||
/// Get the slice of content for a given range
|
||||
pub fn getContentSlice(content: []const u8, range: Range) []const u8 {
|
||||
const start = @min(range.start, content.len);
|
||||
const end = @min(range.actualEnd(content.len), content.len - 1);
|
||||
if (start > end or start >= content.len) return content[0..0];
|
||||
return content[start..end + 1];
|
||||
}
|
||||
|
||||
/// Calculate the total length of all ranges combined
|
||||
pub fn calculateTotalRangeLength(ranges: []const Range, content_size: u64) u64 {
|
||||
var total: u64 = 0;
|
||||
for (ranges) |range| {
|
||||
total += range.length(content_size);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Merge overlapping ranges (optimization for multiple ranges)
|
||||
pub fn mergeOverlappingRanges(ranges: []Range, allocator: std.mem.Allocator) ![]Range {
|
||||
if (ranges.len <= 1) return try allocator.dupe(Range, ranges);
|
||||
|
||||
// Sort ranges by start position
|
||||
std.mem.sort(Range, ranges, {}, struct {
|
||||
fn lessThan(_: void, a: Range, b: Range) bool {
|
||||
return a.start < b.start;
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
var result = std.ArrayList(Range).init(allocator);
|
||||
errdefer result.deinit();
|
||||
|
||||
var current = ranges[0];
|
||||
|
||||
for (ranges[1..]) |range| {
|
||||
const current_end = current.end orelse std.math.maxInt(u64);
|
||||
|
||||
// Check if ranges overlap or are adjacent
|
||||
if (range.start <= current_end + 1) {
|
||||
// Merge ranges
|
||||
const range_end = range.end orelse std.math.maxInt(u64);
|
||||
if (current.end == null or range.end == null) {
|
||||
current.end = null; // Open-ended
|
||||
} else {
|
||||
current.end = @max(current_end, range_end);
|
||||
}
|
||||
} else {
|
||||
// No overlap, add current range and start new one
|
||||
try result.append(current);
|
||||
current = range;
|
||||
}
|
||||
}
|
||||
|
||||
try result.append(current);
|
||||
return try result.toOwnedSlice();
|
||||
}
|
||||
|
||||
test "parseRangeSpec - closed range" {
|
||||
const range_req = try parseRangeSpec("200-1000");
|
||||
try std.testing.expect(range_req == .range);
|
||||
try std.testing.expectEqual(@as(u64, 200), range_req.range.start);
|
||||
try std.testing.expectEqual(@as(?u64, 1000), range_req.range.end);
|
||||
}
|
||||
|
||||
test "parseRangeSpec - open range" {
|
||||
const range_req = try parseRangeSpec("200-");
|
||||
try std.testing.expect(range_req == .range);
|
||||
try std.testing.expectEqual(@as(u64, 200), range_req.range.start);
|
||||
try std.testing.expectEqual(@as(?u64, null), range_req.range.end);
|
||||
}
|
||||
|
||||
test "parseRangeSpec - suffix range" {
|
||||
const range_req = try parseRangeSpec("-500");
|
||||
try std.testing.expect(range_req == .suffix);
|
||||
try std.testing.expectEqual(@as(u64, 500), range_req.suffix.suffix_length);
|
||||
}
|
||||
|
||||
test "parseRangeHeader - single range" {
|
||||
const allocator = std.testing.allocator;
|
||||
const ranges = try parseRangeHeader("bytes=200-1000", allocator);
|
||||
defer allocator.free(ranges);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), ranges.len);
|
||||
try std.testing.expect(ranges[0] == .range);
|
||||
try std.testing.expectEqual(@as(u64, 200), ranges[0].range.start);
|
||||
try std.testing.expectEqual(@as(?u64, 1000), ranges[0].range.end);
|
||||
}
|
||||
|
||||
test "parseRangeHeader - multiple ranges" {
|
||||
const allocator = std.testing.allocator;
|
||||
const ranges = try parseRangeHeader("bytes=0-499, 1000-1499, -500", allocator);
|
||||
defer allocator.free(ranges);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 3), ranges.len);
|
||||
|
||||
// First range
|
||||
try std.testing.expect(ranges[0] == .range);
|
||||
try std.testing.expectEqual(@as(u64, 0), ranges[0].range.start);
|
||||
try std.testing.expectEqual(@as(?u64, 499), ranges[0].range.end);
|
||||
|
||||
// Second range
|
||||
try std.testing.expect(ranges[1] == .range);
|
||||
try std.testing.expectEqual(@as(u64, 1000), ranges[1].range.start);
|
||||
try std.testing.expectEqual(@as(?u64, 1499), ranges[1].range.end);
|
||||
|
||||
// Third range (suffix)
|
||||
try std.testing.expect(ranges[2] == .suffix);
|
||||
try std.testing.expectEqual(@as(u64, 500), ranges[2].suffix.suffix_length);
|
||||
}
|
||||
|
||||
test "Range.isValid" {
|
||||
const range1 = Range{ .start = 200, .end = 1000 };
|
||||
try std.testing.expect(range1.isValid(2000));
|
||||
try std.testing.expect(!range1.isValid(500));
|
||||
|
||||
const range2 = Range{ .start = 200, .end = null };
|
||||
try std.testing.expect(range2.isValid(2000));
|
||||
try std.testing.expect(!range2.isValid(200));
|
||||
}
|
||||
|
||||
test "formatContentRangeHeader" {
|
||||
const allocator = std.testing.allocator;
|
||||
const range = Range{ .start = 200, .end = 1000 };
|
||||
const header = try formatContentRangeHeader(range, 2000, allocator);
|
||||
defer allocator.free(header);
|
||||
|
||||
try std.testing.expectEqualStrings("bytes 200-1000/2000", header);
|
||||
}
|
||||
|
||||
test "getContentSlice" {
|
||||
const content = "Hello, World! This is a test content.";
|
||||
const range = Range{ .start = 7, .end = 12 };
|
||||
const slice = getContentSlice(content, range);
|
||||
|
||||
try std.testing.expectEqualStrings("World!", slice);
|
||||
}
|
||||
|
||||
test "mergeOverlappingRanges" {
|
||||
const allocator = std.testing.allocator;
|
||||
var ranges = [_]Range{
|
||||
Range{ .start = 0, .end = 100 },
|
||||
Range{ .start = 50, .end = 150 },
|
||||
Range{ .start = 200, .end = 300 },
|
||||
Range{ .start = 250, .end = 350 },
|
||||
};
|
||||
|
||||
const merged = try mergeOverlappingRanges(&ranges, allocator);
|
||||
defer allocator.free(merged);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), merged.len);
|
||||
try std.testing.expectEqual(@as(u64, 0), merged[0].start);
|
||||
try std.testing.expectEqual(@as(?u64, 150), merged[0].end);
|
||||
try std.testing.expectEqual(@as(u64, 200), merged[1].start);
|
||||
try std.testing.expectEqual(@as(?u64, 350), merged[1].end);
|
||||
}
|
||||
290
test/js/bun/http/bun-serve-content-range.test.ts
Normal file
290
test/js/bun/http/bun-serve-content-range.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, test } from "bun:test";
|
||||
import { bunEnv, bunExe, isWindows } from "harness";
|
||||
import type { Server } from "bun";
|
||||
|
||||
describe("Content-Range support in static routes", () => {
|
||||
let server: Server;
|
||||
const port = 9999;
|
||||
const baseURL = `http://localhost:${port}`;
|
||||
|
||||
// Test content of various sizes
|
||||
const smallContent = "Hello, World! This is a small test content for range requests.";
|
||||
const mediumContent = "x".repeat(10000); // 10KB
|
||||
const largeContent = "y".repeat(100000); // 100KB
|
||||
|
||||
const routes = {
|
||||
"/small": new Response(smallContent, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}),
|
||||
"/medium": new Response(mediumContent, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}),
|
||||
"/large": new Response(largeContent, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}),
|
||||
"/empty": new Response("", {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}),
|
||||
"/with-etag": new Response("Content with ETag", {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"ETag": '"custom-etag"',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
server = Bun.serve({
|
||||
port,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const response = routes[url.pathname];
|
||||
if (response) {
|
||||
return response.clone();
|
||||
}
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
static: routes,
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (server) {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("normal GET request includes Accept-Ranges header", async () => {
|
||||
const response = await fetch(`${baseURL}/small`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
expect(await response.text()).toBe(smallContent);
|
||||
});
|
||||
|
||||
test("single range request - start and end specified", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: "bytes=7-12" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get("Content-Range")).toBe(`bytes 7-12/${smallContent.length}`);
|
||||
expect(response.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
expect(response.headers.get("Content-Length")).toBe("6");
|
||||
expect(await response.text()).toBe("World!");
|
||||
});
|
||||
|
||||
test("single range request - start only (open-ended)", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: "bytes=7-" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get("Content-Range")).toBe(`bytes 7-${smallContent.length - 1}/${smallContent.length}`);
|
||||
expect(response.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
expect(await response.text()).toBe(smallContent.slice(7));
|
||||
});
|
||||
|
||||
test("suffix range request - last N bytes", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: "bytes=-13" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get("Content-Range")).toBe(`bytes ${smallContent.length - 13}-${smallContent.length - 1}/${smallContent.length}`);
|
||||
expect(response.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
expect(await response.text()).toBe(smallContent.slice(-13));
|
||||
});
|
||||
|
||||
test("range request for entire content returns 200 instead of 206", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: `bytes=0-${smallContent.length - 1}` },
|
||||
});
|
||||
|
||||
// Should return full content with 200 status when range covers entire content
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
expect(await response.text()).toBe(smallContent);
|
||||
});
|
||||
|
||||
test("invalid range - start greater than content length", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: `bytes=${smallContent.length + 10}-` },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(416);
|
||||
expect(response.headers.get("Content-Range")).toBe(`bytes */${smallContent.length}`);
|
||||
expect(await response.text()).toBe("");
|
||||
});
|
||||
|
||||
test("invalid range - end before start", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: "bytes=20-10" },
|
||||
});
|
||||
|
||||
// Invalid range spec should fall back to full content
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(smallContent);
|
||||
});
|
||||
|
||||
test("malformed range header", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: "invalid-range-header" },
|
||||
});
|
||||
|
||||
// Invalid range header should fall back to full content
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(smallContent);
|
||||
});
|
||||
|
||||
test("range request on empty content", async () => {
|
||||
const response = await fetch(`${baseURL}/empty`, {
|
||||
headers: { Range: "bytes=0-10" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(416);
|
||||
expect(response.headers.get("Content-Range")).toBe("bytes */0");
|
||||
});
|
||||
|
||||
test("range request preserves original headers", async () => {
|
||||
const response = await fetch(`${baseURL}/with-etag`, {
|
||||
headers: { Range: "bytes=0-7" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain");
|
||||
expect(response.headers.get("ETag")).toBe('"custom-etag"');
|
||||
expect(response.headers.get("Content-Range")).toBe("bytes 0-7/17");
|
||||
expect(await response.text()).toBe("Content ");
|
||||
});
|
||||
|
||||
test("multiple ranges fall back to full content", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: "bytes=0-10,20-30" },
|
||||
});
|
||||
|
||||
// Multiple ranges not implemented yet, should fall back to full content
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(smallContent);
|
||||
});
|
||||
|
||||
test("large content range request", async () => {
|
||||
const response = await fetch(`${baseURL}/large`, {
|
||||
headers: { Range: "bytes=1000-2000" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get("Content-Range")).toBe(`bytes 1000-2000/${largeContent.length}`);
|
||||
expect(response.headers.get("Content-Length")).toBe("1001");
|
||||
expect(await response.text()).toBe(largeContent.slice(1000, 2001));
|
||||
});
|
||||
|
||||
test("range request at content boundaries", async () => {
|
||||
// Test range at the very end of content
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: `bytes=${smallContent.length - 1}-${smallContent.length - 1}` },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get("Content-Range")).toBe(`bytes ${smallContent.length - 1}-${smallContent.length - 1}/${smallContent.length}`);
|
||||
expect(response.headers.get("Content-Length")).toBe("1");
|
||||
expect(await response.text()).toBe(smallContent.slice(-1));
|
||||
});
|
||||
|
||||
test("suffix range larger than content", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: `bytes=-${smallContent.length + 100}` },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get("Content-Range")).toBe(`bytes 0-${smallContent.length - 1}/${smallContent.length}`);
|
||||
expect(await response.text()).toBe(smallContent);
|
||||
});
|
||||
|
||||
test("HEAD request with range should not include body", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
method: "HEAD",
|
||||
headers: { Range: "bytes=7-12" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get("Content-Range")).toBe(`bytes 7-12/${smallContent.length}`);
|
||||
expect(response.headers.get("Content-Length")).toBe("6");
|
||||
expect(await response.text()).toBe("");
|
||||
});
|
||||
|
||||
test("range request with If-None-Match (cache validation)", async () => {
|
||||
// First get the ETag
|
||||
const initialResponse = await fetch(`${baseURL}/with-etag`);
|
||||
const etag = initialResponse.headers.get("ETag");
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
// Then make a range request with If-None-Match
|
||||
const response = await fetch(`${baseURL}/with-etag`, {
|
||||
headers: {
|
||||
Range: "bytes=0-7",
|
||||
"If-None-Match": etag!,
|
||||
},
|
||||
});
|
||||
|
||||
// Should return 304 Not Modified, not 206 Partial Content
|
||||
expect(response.status).toBe(304);
|
||||
});
|
||||
|
||||
test("range request on medium-sized content", async () => {
|
||||
const response = await fetch(`${baseURL}/medium`, {
|
||||
headers: { Range: "bytes=100-199" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get("Content-Range")).toBe(`bytes 100-199/${mediumContent.length}`);
|
||||
expect(response.headers.get("Content-Length")).toBe("100");
|
||||
expect(await response.text()).toBe(mediumContent.slice(100, 200));
|
||||
});
|
||||
|
||||
test("range request with whitespace in header", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: " bytes=7 - 12 " },
|
||||
});
|
||||
|
||||
// Should handle whitespace gracefully
|
||||
expect(response.status).toBe(200); // Falls back to full content due to parsing
|
||||
expect(await response.text()).toBe(smallContent);
|
||||
});
|
||||
|
||||
test("case insensitive range unit", async () => {
|
||||
const response = await fetch(`${baseURL}/small`, {
|
||||
headers: { Range: "BYTES=7-12" },
|
||||
});
|
||||
|
||||
// Case insensitive parsing is not implemented, should fall back
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(smallContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content-Range unit tests", () => {
|
||||
test("ContentRange.zig unit tests should pass", async () => {
|
||||
// Run the built-in unit tests for ContentRange.zig
|
||||
using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "bd", "test", "src/http/ContentRange.zig"],
|
||||
env: bunEnv,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
console.error("ContentRange.zig test stderr:", stderr);
|
||||
console.error("ContentRange.zig test stdout:", stdout);
|
||||
}
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user