Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
535fabc46e Add Content-Range support for StaticRoute
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>
2025-07-31 01:38:21 +00:00
5 changed files with 838 additions and 0 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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
View 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);
}

View 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);
});
});