mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
Implements HTTP Range requests (RFC 7233) for static routes in Bun.serve(), enabling partial content delivery for better streaming and download resumption. Features: - Parse Range header with support for: - Single ranges: "bytes=200-1000" - Open ranges: "bytes=200-" - Suffix ranges: "bytes=-500" - Generate appropriate Content-Range response headers - Return 206 Partial Content for valid ranges - Return 416 Range Not Satisfiable for invalid ranges - Add Accept-Ranges: bytes header to indicate range support - Preserve ETag validation with If-None-Match - Comprehensive test coverage Implementation includes: - New ContentRange.zig module with parsing and validation logic - Integration with StaticRoute.zig for seamless range handling - PendingRangeResponse for async streaming of large ranges - Fallback to full content for unsupported scenarios (multipart ranges) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
320 lines
11 KiB
Zig
320 lines
11 KiB
Zig
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);
|
|
} |