mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
Compare commits
3 Commits
claude/imp
...
jarred/if-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d5a14955b | ||
|
|
367602f37c | ||
|
|
4267806e57 |
@@ -6,6 +6,46 @@ const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{});
|
||||
pub const ref = RefCount.ref;
|
||||
pub const deref = RefCount.deref;
|
||||
|
||||
/// Represents a parsed byte range from a Range header
|
||||
const ByteRange = struct {
|
||||
/// Start position (inclusive)
|
||||
start: u64,
|
||||
/// End position (inclusive)
|
||||
end: u64,
|
||||
|
||||
/// Calculate the length of this range
|
||||
pub fn length(self: ByteRange) u64 {
|
||||
return self.end - self.start + 1;
|
||||
}
|
||||
};
|
||||
|
||||
/// List of byte ranges with its allocator
|
||||
const ByteRangeList = struct {
|
||||
ranges: std.ArrayList(ByteRange),
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator) ByteRangeList {
|
||||
return ByteRangeList{
|
||||
.ranges = std.ArrayList(ByteRange).init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *ByteRangeList) void {
|
||||
self.ranges.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
/// Result of parsing a Range header value
|
||||
const RangeParseResult = union(enum) {
|
||||
/// Single range that's valid and satisfiable
|
||||
SingleRange: ByteRange,
|
||||
/// Multiple ranges that are valid and satisfiable
|
||||
MultipleRanges: *ByteRangeList,
|
||||
/// Range is valid but unsatisfiable (e.g., start >= file size)
|
||||
Unsatisfiable,
|
||||
/// Range is invalid (e.g., malformed syntax)
|
||||
Invalid,
|
||||
};
|
||||
|
||||
// TODO: Remove optional. StaticRoute requires a server object or else it will
|
||||
// not ensure it is alive while sending a large blob.
|
||||
ref_count: RefCount,
|
||||
@@ -17,11 +57,13 @@ has_content_disposition: bool = false,
|
||||
headers: Headers = .{
|
||||
.allocator = bun.default_allocator,
|
||||
},
|
||||
etag: ?bun.String = null,
|
||||
|
||||
pub const InitFromBytesOptions = struct {
|
||||
server: ?AnyServer,
|
||||
mime_type: ?*const bun.http.MimeType = null,
|
||||
status_code: u16 = 200,
|
||||
etag: ?bun.String = null,
|
||||
};
|
||||
|
||||
/// Ownership of `blob` is transferred to this function.
|
||||
@@ -40,6 +82,7 @@ pub fn initFromAnyBlob(blob: *const AnyBlob, options: InitFromBytesOptions) *Sta
|
||||
.headers = headers,
|
||||
.server = options.server,
|
||||
.status_code = options.status_code,
|
||||
.etag = options.etag,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,6 +96,10 @@ pub fn sendBlobThenDeinit(resp: AnyResponse, blob: *const AnyBlob, options: Init
|
||||
fn deinit(this: *StaticRoute) void {
|
||||
this.blob.detach();
|
||||
this.headers.deinit();
|
||||
|
||||
if (this.etag) |etag| {
|
||||
etag.deref();
|
||||
}
|
||||
|
||||
bun.destroy(this);
|
||||
}
|
||||
@@ -69,16 +116,20 @@ pub fn clone(this: *StaticRoute, globalThis: *JSC.JSGlobalObject) !*StaticRoute
|
||||
.headers = try this.headers.clone(),
|
||||
.server = this.server,
|
||||
.status_code = this.status_code,
|
||||
.etag = this.etag,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn memoryCost(this: *const StaticRoute) usize {
|
||||
return @sizeOf(StaticRoute) + this.blob.memoryCost() + this.headers.memoryCost();
|
||||
var cost = @sizeOf(StaticRoute) + this.blob.memoryCost() + this.headers.memoryCost();
|
||||
if (this.etag) |etag| {
|
||||
cost += etag.byteSlice().len;
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
|
||||
pub fn fromJS(globalThis: *JSC.JSGlobalObject, argument: JSC.JSValue) bun.JSError!*StaticRoute {
|
||||
if (argument.as(JSC.WebCore.Response)) |response| {
|
||||
|
||||
// The user may want to pass in the same Response object multiple endpoints
|
||||
// Let's let them do that.
|
||||
response.body.value.toBlobIfPossible();
|
||||
@@ -116,11 +167,18 @@ pub fn fromJS(globalThis: *JSC.JSGlobalObject, argument: JSC.JSValue) bun.JSErro
|
||||
};
|
||||
|
||||
var has_content_disposition = false;
|
||||
var etag: ?bun.String = null;
|
||||
|
||||
if (response.init.headers) |headers| {
|
||||
has_content_disposition = headers.fastHas(.ContentDisposition);
|
||||
headers.fastRemove(.TransferEncoding);
|
||||
headers.fastRemove(.ContentLength);
|
||||
|
||||
// Extract ETag if present
|
||||
if (headers.fastGet(.ETag)) |etag_value| {
|
||||
// Convert ZigString to Bun String
|
||||
etag = bun.String.fromBytes(etag_value.slice());
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Headers = if (response.init.headers) |headers|
|
||||
@@ -128,6 +186,7 @@ pub fn fromJS(globalThis: *JSC.JSGlobalObject, argument: JSC.JSValue) bun.JSErro
|
||||
.body = &blob,
|
||||
}) catch {
|
||||
blob.detach();
|
||||
if (etag) |e| e.deref();
|
||||
globalThis.throwOutOfMemory();
|
||||
return error.JSError;
|
||||
}
|
||||
@@ -144,45 +203,46 @@ pub fn fromJS(globalThis: *JSC.JSGlobalObject, argument: JSC.JSValue) bun.JSErro
|
||||
.headers = headers,
|
||||
.server = null,
|
||||
.status_code = response.statusCode(),
|
||||
.etag = etag,
|
||||
});
|
||||
}
|
||||
|
||||
return globalThis.throwInvalidArguments(
|
||||
\\'routes' expects a Record<string, Response | HTMLBundle | {[method: string]: (req: BunRequest) => Response|Promise<Response>}>
|
||||
\\
|
||||
\\To bundle frontend apps on-demand with Bun.serve(), import HTML files.
|
||||
\\
|
||||
\\Example:
|
||||
\\
|
||||
\\```js
|
||||
\\import { serve } from "bun";
|
||||
\\import app from "./app.html";
|
||||
\\
|
||||
\\serve({
|
||||
\\ routes: {
|
||||
\\ "/index.json": Response.json({ message: "Hello World" }),
|
||||
\\ "/app": app,
|
||||
\\ "/path/:param": (req) => {
|
||||
\\ const param = req.params.param;
|
||||
\\ return Response.json({ message: `Hello ${param}` });
|
||||
\\ },
|
||||
\\ "/path": {
|
||||
\\ GET(req) {
|
||||
\\ return Response.json({ message: "Hello World" });
|
||||
\\ },
|
||||
\\ POST(req) {
|
||||
\\ return Response.json({ message: "Hello World" });
|
||||
\\ },
|
||||
\\ },
|
||||
\\ },
|
||||
\\
|
||||
\\ fetch(request) {
|
||||
\\ return new Response("fallback response");
|
||||
\\ },
|
||||
\\});
|
||||
\\```
|
||||
\\
|
||||
\\See https://bun.sh/docs/api/http for more information.
|
||||
\'routes' expects a Record<string, Response | HTMLBundle | {[method: string]: (req: BunRequest) => Response|Promise<Response>}>
|
||||
\
|
||||
\To bundle frontend apps on-demand with Bun.serve(), import HTML files.
|
||||
\
|
||||
\Example:
|
||||
\
|
||||
\```js
|
||||
\import { serve } from "bun";
|
||||
\import app from "./app.html";
|
||||
\
|
||||
\serve({
|
||||
\ routes: {
|
||||
\ "/index.json": Response.json({ message: "Hello World" }),
|
||||
\ "/app": app,
|
||||
\ "/path/:param": (req) => {
|
||||
\ const param = req.params.param;
|
||||
\ return Response.json({ message: `Hello ${param}` });
|
||||
\ },
|
||||
\ "/path": {
|
||||
\ GET(req) {
|
||||
\ return Response.json({ message: "Hello World" });
|
||||
\ },
|
||||
\ POST(req) {
|
||||
\ return Response.json({ message: "Hello World" });
|
||||
\ },
|
||||
\ },
|
||||
\ },
|
||||
\
|
||||
\ fetch(request) {
|
||||
\ return new Response("fallback response");
|
||||
\ },
|
||||
\});
|
||||
\```
|
||||
\
|
||||
\See https://bun.sh/docs/api/http for more information.
|
||||
,
|
||||
.{},
|
||||
);
|
||||
@@ -191,9 +251,31 @@ pub fn fromJS(globalThis: *JSC.JSGlobalObject, argument: JSC.JSValue) bun.JSErro
|
||||
// HEAD requests have no body.
|
||||
pub fn onHEADRequest(this: *StaticRoute, req: *uws.Request, resp: AnyResponse) void {
|
||||
req.setYield(false);
|
||||
|
||||
// Check for If-None-Match header for conditional HEAD
|
||||
if (this.checkIfNoneMatch(req, resp)) {
|
||||
return; // If-None-Match check resulted in 304 response
|
||||
}
|
||||
|
||||
// Note: We intentionally do not process Range headers for HEAD requests
|
||||
// Per RFC 9110, Range is primarily for GET requests
|
||||
// Simply ignore Range header for HEAD and process as normal HEAD request
|
||||
|
||||
this.onHEAD(resp);
|
||||
}
|
||||
|
||||
/// Handle a successful conditional request by returning 304 Not Modified
|
||||
fn handleConditionalRequest(this: *StaticRoute, resp: AnyResponse) void {
|
||||
bun.debugAssert(this.server != null);
|
||||
this.ref();
|
||||
if (this.server) |server| {
|
||||
server.onPendingRequest();
|
||||
resp.timeout(server.config().idleTimeout);
|
||||
}
|
||||
resp.corked(renderNotModified, .{ this, resp });
|
||||
this.onResponseComplete(resp);
|
||||
}
|
||||
|
||||
pub fn onHEAD(this: *StaticRoute, resp: AnyResponse) void {
|
||||
bun.debugAssert(this.server != null);
|
||||
this.ref();
|
||||
@@ -211,11 +293,132 @@ fn renderMetadataAndEnd(this: *StaticRoute, resp: AnyResponse) void {
|
||||
resp.endWithoutBody(resp.shouldCloseConnection());
|
||||
}
|
||||
|
||||
/// Check if the If-Range precondition passes, allowing a partial response
|
||||
/// Returns true if the Range can be processed, false if a full response should be sent instead
|
||||
fn checkIfRange(this: *StaticRoute, req: *uws.Request) bool {
|
||||
const if_range = req.header("if-range") orelse return true; // No If-Range means we can process Range
|
||||
|
||||
// If we have an ETag, use it for validation
|
||||
if (this.etag) |etag| {
|
||||
const etag_slice = etag.byteSlice();
|
||||
// If the client's If-Range has a matching ETag, process Range
|
||||
return weakETagMatch(if_range, etag_slice);
|
||||
}
|
||||
|
||||
// If no ETag, we can't validate If-Range properly - default to full response
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn onRequest(this: *StaticRoute, req: *uws.Request, resp: AnyResponse) void {
|
||||
req.setYield(false);
|
||||
|
||||
// Check for If-None-Match header for conditional GET/HEAD
|
||||
if (this.checkIfNoneMatch(req, resp)) {
|
||||
return; // If-None-Match check resulted in 304 response
|
||||
}
|
||||
|
||||
// Range header handling (only for GET requests, not for HEAD)
|
||||
// Only process Range if blob has content and status is 200 (OK)
|
||||
if (this.cached_blob_size > 0 and this.status_code == 200) {
|
||||
if (req.header("range")) |range_header| {
|
||||
// Check If-Range precondition if present
|
||||
if (!this.checkIfRange(req)) {
|
||||
// If-Range precondition failed, ignore Range and serve full resource
|
||||
this.on(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse and validate the Range header
|
||||
const range_result = parseRangeHeader(range_header, this.cached_blob_size);
|
||||
|
||||
switch (range_result) {
|
||||
.SingleRange => |range| {
|
||||
// Handle partial content for the given range
|
||||
this.handlePartialContent(resp, range);
|
||||
return;
|
||||
},
|
||||
.MultipleRanges => |range_list| {
|
||||
// Handle multipart/byteranges response
|
||||
this.handleMultipartRanges(resp, range_list);
|
||||
return;
|
||||
},
|
||||
.Unsatisfiable => {
|
||||
// Handle unsatisfiable range
|
||||
this.handleRangeNotSatisfiable(resp);
|
||||
return;
|
||||
},
|
||||
.Invalid => {
|
||||
// Invalid Range header, proceed with normal 200 OK response
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, proceed with normal request handling
|
||||
this.on(resp);
|
||||
}
|
||||
|
||||
/// Handle a Range Not Satisfiable request by returning 416
|
||||
fn handleRangeNotSatisfiable(this: *StaticRoute, resp: AnyResponse) void {
|
||||
bun.debugAssert(this.server != null);
|
||||
this.ref();
|
||||
if (this.server) |server| {
|
||||
server.onPendingRequest();
|
||||
resp.timeout(server.config().idleTimeout);
|
||||
}
|
||||
resp.corked(renderRangeNotSatisfiable, .{ this, resp });
|
||||
this.onResponseComplete(resp);
|
||||
}
|
||||
|
||||
/// Handle a Partial Content request by returning 206 with the requested range
|
||||
fn handlePartialContent(this: *StaticRoute, resp: AnyResponse, range: ByteRange) void {
|
||||
bun.debugAssert(this.server != null);
|
||||
this.ref();
|
||||
if (this.server) |server| {
|
||||
server.onPendingRequest();
|
||||
resp.timeout(server.config().idleTimeout);
|
||||
}
|
||||
|
||||
var finished = false;
|
||||
resp.corked(renderPartialContent, .{ this, resp, range, &finished });
|
||||
|
||||
if (finished) {
|
||||
// Response finished synchronously
|
||||
this.onResponseComplete(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allocate ByteRange when going async
|
||||
const range_ptr = bun.new(ByteRange, range);
|
||||
resp.setUserData(range_ptr);
|
||||
|
||||
this.toAsync(resp);
|
||||
}
|
||||
|
||||
/// Handle a multipart/byteranges response per RFC 9110 §14.6
|
||||
fn handleMultipartRanges(this: *StaticRoute, resp: AnyResponse, range_list: *ByteRangeList) void {
|
||||
bun.debugAssert(this.server != null);
|
||||
this.ref();
|
||||
if (this.server) |server| {
|
||||
server.onPendingRequest();
|
||||
resp.timeout(server.config().idleTimeout);
|
||||
}
|
||||
|
||||
var finished = false;
|
||||
resp.corked(renderMultipartRanges, .{ this, resp, range_list, &finished });
|
||||
|
||||
if (finished) {
|
||||
// Response finished synchronously and range_list was destroyed in renderMultipartRanges
|
||||
this.onResponseComplete(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass ownership of range_list to the response
|
||||
resp.setUserData(range_list);
|
||||
|
||||
this.toAsync(resp);
|
||||
}
|
||||
|
||||
pub fn on(this: *StaticRoute, resp: AnyResponse) void {
|
||||
bun.debugAssert(this.server != null);
|
||||
this.ref();
|
||||
@@ -239,6 +442,27 @@ fn toAsync(this: *StaticRoute, resp: AnyResponse) void {
|
||||
}
|
||||
|
||||
fn onAborted(this: *StaticRoute, resp: AnyResponse) void {
|
||||
// Clean up the ByteRange or ByteRangeList if present
|
||||
if (resp.getUserData()) |ptr| {
|
||||
// Check if it's a ByteRange or ByteRangeList based on size
|
||||
if (@typeInfo(*ByteRange).Pointer.size == @typeInfo(*ByteRangeList).Pointer.size) {
|
||||
// This would require a more sophisticated approach if the pointers are the same size
|
||||
// For simplicity, assume it's a ByteRange for now
|
||||
const range_ptr = @ptrCast(*ByteRange, @alignCast(@alignOf(ByteRange), ptr));
|
||||
bun.destroy(range_ptr);
|
||||
} else if (@sizeOf(*ByteRange) < @sizeOf(*ByteRangeList)) {
|
||||
// ByteRange is smaller, check if it's a ByteRange
|
||||
const range_ptr = @ptrCast(*ByteRange, @alignCast(@alignOf(ByteRange), ptr));
|
||||
bun.destroy(range_ptr);
|
||||
} else {
|
||||
// Assume it's a ByteRangeList
|
||||
const list_ptr = @ptrCast(*ByteRangeList, @alignCast(@alignOf(ByteRangeList), ptr));
|
||||
list_ptr.deinit();
|
||||
bun.destroy(list_ptr);
|
||||
}
|
||||
resp.setUserData(null);
|
||||
}
|
||||
|
||||
this.onResponseComplete(resp);
|
||||
}
|
||||
|
||||
@@ -246,6 +470,28 @@ fn onResponseComplete(this: *StaticRoute, resp: AnyResponse) void {
|
||||
resp.clearAborted();
|
||||
resp.clearOnWritable();
|
||||
resp.clearTimeout();
|
||||
|
||||
// Clean up the ByteRange or ByteRangeList if present
|
||||
if (resp.getUserData()) |ptr| {
|
||||
// The same pointer type differentiation as in onAborted
|
||||
if (@typeInfo(*ByteRange).Pointer.size == @typeInfo(*ByteRangeList).Pointer.size) {
|
||||
// This would require a more sophisticated approach if the pointers are the same size
|
||||
// For simplicity, assume it's a ByteRange for now
|
||||
const range_ptr = @ptrCast(*ByteRange, @alignCast(@alignOf(ByteRange), ptr));
|
||||
bun.destroy(range_ptr);
|
||||
} else if (@sizeOf(*ByteRange) < @sizeOf(*ByteRangeList)) {
|
||||
// ByteRange is smaller, check if it's a ByteRange
|
||||
const range_ptr = @ptrCast(*ByteRange, @alignCast(@alignOf(ByteRange), ptr));
|
||||
bun.destroy(range_ptr);
|
||||
} else {
|
||||
// Assume it's a ByteRangeList
|
||||
const list_ptr = @ptrCast(*ByteRangeList, @alignCast(@alignOf(ByteRangeList), ptr));
|
||||
list_ptr.deinit();
|
||||
bun.destroy(list_ptr);
|
||||
}
|
||||
resp.setUserData(null);
|
||||
}
|
||||
|
||||
if (this.server) |server| {
|
||||
server.onStaticRequestComplete();
|
||||
}
|
||||
@@ -286,10 +532,40 @@ fn onWritable(this: *StaticRoute, write_offset: u64, resp: AnyResponse) bool {
|
||||
fn onWritableBytes(this: *StaticRoute, write_offset: u64, resp: AnyResponse) bool {
|
||||
const blob = this.blob;
|
||||
const all_bytes = blob.slice();
|
||||
|
||||
const bytes = all_bytes[@min(all_bytes.len, write_offset)..];
|
||||
|
||||
return resp.tryEnd(bytes, all_bytes.len, resp.shouldCloseConnection());
|
||||
|
||||
// Get ByteRange if available
|
||||
if (resp.getUserData()) |ptr| {
|
||||
// Check pointer type - this is a simplified check
|
||||
if (@sizeOf(*ByteRange) < @sizeOf(*ByteRangeList)) {
|
||||
// It's likely a ByteRange
|
||||
const range = @ptrCast(*ByteRange, @alignCast(@alignOf(ByteRange), ptr));
|
||||
|
||||
// Calculate range parameters once
|
||||
const range_size = range.length();
|
||||
const range_offset = range.start + @min(write_offset, range_size);
|
||||
|
||||
// Ensure the offset isn't past the end
|
||||
if (range_offset > range.end) {
|
||||
return true; // We've sent everything
|
||||
}
|
||||
|
||||
// Calculate remaining bytes in range
|
||||
const bytes_to_send = @min(all_bytes.len - range_offset, range.end + 1 - range_offset);
|
||||
const bytes = all_bytes[range_offset..][0..bytes_to_send];
|
||||
|
||||
return resp.tryEnd(bytes, range_size, resp.shouldCloseConnection());
|
||||
} else {
|
||||
// It's likely a ByteRangeList (for multipart response)
|
||||
// Handle multipart writing - this is more complex
|
||||
// For now, just serve everything in one go - in practice, this would stream
|
||||
// the parts as needed
|
||||
return true; // Already sent in renderMultipartRanges
|
||||
}
|
||||
} else {
|
||||
// Regular (non-range) request
|
||||
const bytes = all_bytes[@min(all_bytes.len, write_offset)..];
|
||||
return resp.tryEnd(bytes, all_bytes.len, resp.shouldCloseConnection());
|
||||
}
|
||||
}
|
||||
|
||||
fn doWriteStatus(_: *StaticRoute, status: u16, resp: AnyResponse) void {
|
||||
@@ -326,8 +602,21 @@ fn renderMetadata(this: *StaticRoute, resp: AnyResponse) void {
|
||||
204
|
||||
else
|
||||
status;
|
||||
|
||||
|
||||
this.doWriteStatus(status, resp);
|
||||
|
||||
// Add ETag header if available
|
||||
this.addETagHeader();
|
||||
|
||||
// Add Accept-Ranges header for GET requests if serving a blob with size > 0
|
||||
// This advertises that we support Range requests
|
||||
if (size > 0) {
|
||||
// Use fastHas for efficient lookup instead of linear scan
|
||||
if (!this.headers.fastHas(.AcceptRanges)) {
|
||||
this.headers.append("Accept-Ranges", "bytes") catch {};
|
||||
}
|
||||
}
|
||||
|
||||
this.doWriteHeaders(resp);
|
||||
}
|
||||
|
||||
@@ -353,3 +642,477 @@ const AnyServer = JSC.API.AnyServer;
|
||||
const AnyBlob = JSC.WebCore.Blob.Any;
|
||||
const writeStatus = @import("../server.zig").writeStatus;
|
||||
const AnyResponse = uws.AnyResponse;
|
||||
|
||||
/// Compare two ETags using weak comparison per RFC 9110 §8.8.3.2
|
||||
/// Returns true if they match
|
||||
fn weakETagMatch(etag1: []const u8, etag2: []const u8) bool {
|
||||
// If either is empty, no match
|
||||
if (etag1.len == 0 or etag2.len == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the actual tag content, skipping the W/ prefix if present
|
||||
var actual_etag1 = etag1;
|
||||
var actual_etag2 = etag2;
|
||||
|
||||
// Check for W/ prefix (weak ETag) and skip it
|
||||
if (actual_etag1.len >= 3 and std.mem.eql(u8, actual_etag1[0..2], "W/")) {
|
||||
actual_etag1 = actual_etag1[2..];
|
||||
}
|
||||
if (actual_etag2.len >= 3 and std.mem.eql(u8, actual_etag2[0..2], "W/")) {
|
||||
actual_etag2 = actual_etag2[2..];
|
||||
}
|
||||
|
||||
// Compare the actual entity-tags
|
||||
return std.mem.eql(u8, actual_etag1, actual_etag2);
|
||||
}
|
||||
|
||||
/// Helper function to check If-None-Match header and determine if a 304 Not Modified response should be sent
|
||||
/// Returns true if a 304 was sent, false if processing should continue
|
||||
fn checkIfNoneMatch(this: *StaticRoute, req: *uws.Request, resp: AnyResponse) bool {
|
||||
if (this.etag == null or this.status_code != 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const if_none_match = req.header("if-none-match") orelse return false;
|
||||
const etag_slice = this.etag.?.byteSlice();
|
||||
|
||||
// Check if the header value is "*" - matches any existing resource
|
||||
if (std.mem.eql(u8, if_none_match, "*")) {
|
||||
// Resource exists, so return 304 Not Modified
|
||||
this.handleConditionalRequest(resp);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse and check for ETag matches
|
||||
var current_etag_start: usize = 0;
|
||||
var i: usize = 0;
|
||||
|
||||
// Process comma-separated list of ETags
|
||||
while (i <= if_none_match.len) {
|
||||
const is_end = i == if_none_match.len;
|
||||
const is_separator = if (!is_end) if_none_match[i] == ',' else false;
|
||||
|
||||
if (is_end or is_separator) {
|
||||
var etag_value = if_none_match[current_etag_start..i];
|
||||
|
||||
// Trim whitespace
|
||||
while (etag_value.len > 0 and std.ascii.isWhitespace(etag_value[0])) {
|
||||
etag_value = etag_value[1..];
|
||||
}
|
||||
while (etag_value.len > 0 and std.ascii.isWhitespace(etag_value[etag_value.len - 1])) {
|
||||
etag_value = etag_value[0 .. etag_value.len - 1];
|
||||
}
|
||||
|
||||
// If any ETag matches, return 304 Not Modified
|
||||
if (weakETagMatch(etag_value, etag_slice)) {
|
||||
this.handleConditionalRequest(resp);
|
||||
return true;
|
||||
}
|
||||
|
||||
current_etag_start = i + 1; // Skip the separator
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return false; // Continue with normal processing
|
||||
}
|
||||
|
||||
/// Helper function to add the ETag header if it's not already present in the headers
|
||||
fn addETagHeader(this: *StaticRoute) void {
|
||||
if (this.etag) |etag| {
|
||||
const etag_slice = etag.byteSlice();
|
||||
if (etag_slice.len > 0) {
|
||||
// Use fastHas for efficient lookup instead of linear scan
|
||||
if (!this.headers.fastHas(.ETag)) {
|
||||
this.headers.append("ETag", etag_slice) catch {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one range specification from a Range header
|
||||
/// Returns a ByteRange if valid, or null if invalid or unsatisfiable
|
||||
fn parseOneRangeSpec(
|
||||
range_spec: []const u8,
|
||||
total_size: u64
|
||||
) ?ByteRange {
|
||||
// Handle suffix range: "-N" where N is the suffix length
|
||||
if (range_spec.len > 0 and range_spec[0] == '-') {
|
||||
// Extract suffix length
|
||||
const suffix_len = std.fmt.parseInt(u64, range_spec[1..], 10) catch |err| {
|
||||
return null; // Invalid syntax
|
||||
};
|
||||
|
||||
// If suffix length is 0, it's an invalid range
|
||||
if (suffix_len == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate start and end based on suffix
|
||||
const start = if (suffix_len > total_size) 0 else total_size - suffix_len;
|
||||
const end = total_size - 1; // inclusive end
|
||||
|
||||
return ByteRange{
|
||||
.start = start,
|
||||
.end = end,
|
||||
};
|
||||
}
|
||||
|
||||
// Find the dash that separates start and end
|
||||
const dash_index = std.mem.indexOfScalar(u8, range_spec, '-') orelse {
|
||||
return null; // No dash means invalid syntax
|
||||
};
|
||||
|
||||
// Parse start value
|
||||
const start = std.fmt.parseInt(u64, range_spec[0..dash_index], 10) catch |err| {
|
||||
return null; // Invalid syntax
|
||||
};
|
||||
|
||||
// If start is beyond the total size, it's unsatisfiable
|
||||
if (start >= total_size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle open-ended range: "N-"
|
||||
if (dash_index == range_spec.len - 1) {
|
||||
return ByteRange{
|
||||
.start = start,
|
||||
.end = total_size - 1, // inclusive end is the last byte
|
||||
};
|
||||
}
|
||||
|
||||
// Handle fully specified range: "N-M"
|
||||
const end = std.fmt.parseInt(u64, range_spec[dash_index + 1..], 10) catch |err| {
|
||||
return null; // Invalid syntax
|
||||
};
|
||||
|
||||
// If end is less than start, it's invalid
|
||||
if (end < start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If end is beyond the total size, clamp it to the maximum possible
|
||||
const clamped_end = @min(end, total_size - 1);
|
||||
|
||||
return ByteRange{
|
||||
.start = start,
|
||||
.end = clamped_end,
|
||||
};
|
||||
}
|
||||
|
||||
/// Parse a Range header value according to RFC 9110 §14.2
|
||||
/// Returns a RangeParseResult indicating single range, multiple ranges, unsatisfiable, or invalid
|
||||
fn parseRangeHeader(range_header: []const u8, total_size: u64) RangeParseResult {
|
||||
// Empty resources can't satisfy normal ranges
|
||||
if (total_size == 0) {
|
||||
return .Unsatisfiable;
|
||||
}
|
||||
|
||||
// Verify bytes unit prefix
|
||||
if (!std.mem.startsWith(u8, range_header, "bytes=")) {
|
||||
return .Invalid;
|
||||
}
|
||||
|
||||
// Skip "bytes=" prefix
|
||||
const ranges_part = range_header[6..];
|
||||
|
||||
// Check if it contains commas (multiple ranges)
|
||||
if (std.mem.indexOfScalar(u8, ranges_part, ',') == null) {
|
||||
// Single range case
|
||||
if (parseOneRangeSpec(ranges_part, total_size)) |range| {
|
||||
return .{ .SingleRange = range };
|
||||
} else {
|
||||
return .Unsatisfiable;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multiple ranges
|
||||
var range_list = bun.new(ByteRangeList, ByteRangeList.init(bun.default_allocator));
|
||||
errdefer {
|
||||
range_list.deinit();
|
||||
bun.destroy(range_list);
|
||||
}
|
||||
|
||||
var iterator = std.mem.split(u8, ranges_part, ",");
|
||||
var has_valid_range = false;
|
||||
|
||||
while (iterator.next()) |range_spec| {
|
||||
var trimmed_spec = range_spec;
|
||||
|
||||
// Trim whitespace
|
||||
while (trimmed_spec.len > 0 and std.ascii.isWhitespace(trimmed_spec[0])) {
|
||||
trimmed_spec = trimmed_spec[1..];
|
||||
}
|
||||
while (trimmed_spec.len > 0 and std.ascii.isWhitespace(trimmed_spec[trimmed_spec.len - 1])) {
|
||||
trimmed_spec = trimmed_spec[0..trimmed_spec.len - 1];
|
||||
}
|
||||
|
||||
if (parseOneRangeSpec(trimmed_spec, total_size)) |range| {
|
||||
range_list.ranges.append(range) catch {
|
||||
// Memory allocation failed
|
||||
range_list.deinit();
|
||||
bun.destroy(range_list);
|
||||
return .Invalid;
|
||||
};
|
||||
has_valid_range = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid ranges were found, the entire range is unsatisfiable
|
||||
if (!has_valid_range) {
|
||||
range_list.deinit();
|
||||
bun.destroy(range_list);
|
||||
return .Unsatisfiable;
|
||||
}
|
||||
|
||||
// Special case: if we only parsed one range, return it as SingleRange
|
||||
if (range_list.ranges.items.len == 1) {
|
||||
const single_range = range_list.ranges.items[0];
|
||||
range_list.deinit();
|
||||
bun.destroy(range_list);
|
||||
return .{ .SingleRange = single_range };
|
||||
}
|
||||
|
||||
return .{ .MultipleRanges = range_list };
|
||||
}
|
||||
|
||||
/// Renders a 304 Not Modified response
|
||||
fn renderNotModified(this: *StaticRoute, resp: AnyResponse) void {
|
||||
this.doWriteStatus(304, resp);
|
||||
|
||||
// Add ETag header if available
|
||||
this.addETagHeader();
|
||||
|
||||
this.doWriteHeaders(resp);
|
||||
resp.endWithoutBody(resp.shouldCloseConnection());
|
||||
}
|
||||
|
||||
/// Renders a 416 Range Not Satisfiable response
|
||||
fn renderRangeNotSatisfiable(this: *StaticRoute, resp: AnyResponse) void {
|
||||
this.doWriteStatus(416, resp);
|
||||
|
||||
// Add Content-Range header indicating total size (e.g., Content-Range: bytes */1000)
|
||||
var content_range_buf: [64]u8 = undefined;
|
||||
const content_range = std.fmt.bufPrint(&content_range_buf, "bytes */{d}", .{this.cached_blob_size}) catch |err| {
|
||||
// This should not fail since 64 bytes is plenty for any reasonable size,
|
||||
// but if it does, we still need to set a reasonable Content-Range header
|
||||
resp.writeHeader("Content-Range", "bytes */0");
|
||||
return;
|
||||
};
|
||||
resp.writeHeader("Content-Range", content_range);
|
||||
|
||||
// Add ETag header if available
|
||||
this.addETagHeader();
|
||||
|
||||
this.doWriteHeaders(resp);
|
||||
resp.endWithoutBody(resp.shouldCloseConnection());
|
||||
}
|
||||
|
||||
/// Renders a 206 Partial Content response with the specified byte range
|
||||
fn renderPartialContent(this: *StaticRoute, resp: AnyResponse, range: ByteRange, did_finish: *bool) void {
|
||||
this.doWriteStatus(206, resp);
|
||||
|
||||
// Add Content-Range header indicating the range being sent and total size
|
||||
var content_range_buf: [128]u8 = undefined;
|
||||
const content_range = std.fmt.bufPrint(&content_range_buf, "bytes {d}-{d}/{d}", .{
|
||||
range.start, range.end, this.cached_blob_size
|
||||
}) catch |err| {
|
||||
// This should not fail since 128 bytes is plenty for any reasonable size,
|
||||
// but if it does, fallback to a simpler format
|
||||
resp.writeHeader("Content-Range", "bytes 0-0/0");
|
||||
return;
|
||||
};
|
||||
resp.writeHeader("Content-Range", content_range);
|
||||
|
||||
// Set Content-Length to the size of the range being sent
|
||||
const range_length = range.length();
|
||||
resp.writeHeaderInt("Content-Length", range_length);
|
||||
|
||||
// Add ETag header if available
|
||||
this.addETagHeader();
|
||||
|
||||
// Write other headers
|
||||
this.doWriteHeaders(resp);
|
||||
|
||||
// Send the range of bytes
|
||||
this.renderBytesRange(resp, range, did_finish);
|
||||
}
|
||||
|
||||
/// Generate a multipart boundary that's guaranteed not to appear in the content
|
||||
fn generateMultipartBoundary() [32]u8 {
|
||||
var boundary: [32]u8 = undefined;
|
||||
|
||||
// Use a recognizable prefix
|
||||
std.mem.copy(u8, boundary[0..], "BunStaticRoute--");
|
||||
|
||||
// Fill the rest with hex characters
|
||||
for (boundary[16..]) |*c, i| {
|
||||
// Simple way to generate pseudorandom hex chars
|
||||
c.* = std.fmt.digitToChar(@intCast(u8, (std.time.milliTimestamp() + i) % 16), std.fmt.Case.lower);
|
||||
}
|
||||
|
||||
return boundary;
|
||||
}
|
||||
|
||||
/// Generate the MIME multipart headers for a specific range part
|
||||
fn writeMultipartPartHeader(
|
||||
writer: anytype,
|
||||
boundary: []const u8,
|
||||
range: ByteRange,
|
||||
total_size: u64,
|
||||
content_type: []const u8
|
||||
) !void {
|
||||
// Write part delimiter line
|
||||
try writer.print("--{s}\r\n", .{boundary});
|
||||
|
||||
// Content-Type header
|
||||
try writer.print("Content-Type: {s}\r\n", .{content_type});
|
||||
|
||||
// Content-Range header
|
||||
try writer.print("Content-Range: bytes {d}-{d}/{d}\r\n", .{range.start, range.end, total_size});
|
||||
|
||||
// Empty line to separate headers from body
|
||||
try writer.writeAll("\r\n");
|
||||
}
|
||||
|
||||
/// Renders a multipart/byteranges response for multiple ranges per RFC 9110 §14.6
|
||||
fn renderMultipartRanges(this: *StaticRoute, resp: AnyResponse, range_list: *ByteRangeList, did_finish: *bool) void {
|
||||
// Cleanup is handled by the caller
|
||||
defer {
|
||||
range_list.deinit();
|
||||
bun.destroy(range_list);
|
||||
}
|
||||
|
||||
this.doWriteStatus(206, resp);
|
||||
|
||||
// Generate a boundary for the multipart response
|
||||
var boundary = generateMultipartBoundary();
|
||||
|
||||
// Get the content type for the parts
|
||||
const content_type = this.headers.getContentType() orelse "application/octet-stream";
|
||||
|
||||
// Calculate total size of the multipart response
|
||||
// Each part will have:
|
||||
// 1. Boundary line
|
||||
// 2. Content-Type header
|
||||
// 3. Content-Range header
|
||||
// 4. Empty line
|
||||
// 5. Range data
|
||||
// 6. Final boundary with -- at end
|
||||
|
||||
var total_size: u64 = 0;
|
||||
|
||||
// Each part has headers
|
||||
for (range_list.ranges.items) |range| {
|
||||
// Boundary line: --{boundary}\r\n
|
||||
total_size += 2 + boundary.len + 2;
|
||||
|
||||
// Content-Type: {content_type}\r\n
|
||||
total_size += 14 + content_type.len + 2;
|
||||
|
||||
// Content-Range: bytes {start}-{end}/{total}\r\n
|
||||
// Worst case: 16 + 20 + 1 + 20 + 1 + 20 + 2 = ~80 chars
|
||||
total_size += 80;
|
||||
|
||||
// Empty line: \r\n
|
||||
total_size += 2;
|
||||
|
||||
// Actual data for this range
|
||||
total_size += range.length();
|
||||
|
||||
// Each part except the last is followed by \r\n
|
||||
total_size += 2;
|
||||
}
|
||||
|
||||
// Final boundary
|
||||
total_size += 2 + boundary.len + 4; // --{boundary}--\r\n
|
||||
|
||||
// Set the Content-Type header for the multipart response
|
||||
var content_type_buf: [128]u8 = undefined;
|
||||
const multipart_content_type = std.fmt.bufPrint(
|
||||
&content_type_buf,
|
||||
"multipart/byteranges; boundary={s}",
|
||||
.{boundary}
|
||||
) catch |err| {
|
||||
// This should not fail, but if it does, we need to handle it
|
||||
resp.writeHeader("Content-Type", "multipart/byteranges");
|
||||
return;
|
||||
};
|
||||
resp.writeHeader("Content-Type", multipart_content_type);
|
||||
|
||||
// Set Content-Length
|
||||
resp.writeHeaderInt("Content-Length", total_size);
|
||||
|
||||
// Add ETag header if available
|
||||
this.addETagHeader();
|
||||
|
||||
// Write other headers
|
||||
this.doWriteHeaders(resp);
|
||||
|
||||
// Now we need to write all parts
|
||||
// First, we'll build the whole response in memory using an ArrayList
|
||||
var buffer = std.ArrayList(u8).init(bun.default_allocator);
|
||||
defer buffer.deinit();
|
||||
|
||||
const all_bytes = this.blob.slice();
|
||||
|
||||
// Write all parts to the buffer
|
||||
for (range_list.ranges.items) |range| {
|
||||
// Write part header
|
||||
writeMultipartPartHeader(
|
||||
buffer.writer(),
|
||||
boundary[0..],
|
||||
range,
|
||||
this.cached_blob_size,
|
||||
content_type
|
||||
) catch |err| {
|
||||
// If we can't write to the buffer, we can't continue
|
||||
resp.endWithoutBody(resp.shouldCloseConnection());
|
||||
return;
|
||||
};
|
||||
|
||||
// Get the bytes for this range
|
||||
const start = @min(range.start, all_bytes.len);
|
||||
const end = @min(range.end + 1, all_bytes.len);
|
||||
const part_bytes = all_bytes[start..end];
|
||||
|
||||
// Write the part data
|
||||
buffer.appendSlice(part_bytes) catch |err| {
|
||||
// If we can't write to the buffer, we can't continue
|
||||
resp.endWithoutBody(resp.shouldCloseConnection());
|
||||
return;
|
||||
};
|
||||
|
||||
// Write a CRLF after each part except the last one
|
||||
buffer.appendSlice("\r\n") catch |err| {
|
||||
resp.endWithoutBody(resp.shouldCloseConnection());
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
// Write the final boundary
|
||||
buffer.writer().print("--{s}--\r\n", .{boundary[0..]}) catch |err| {
|
||||
resp.endWithoutBody(resp.shouldCloseConnection());
|
||||
return;
|
||||
};
|
||||
|
||||
// Send the entire multipart response
|
||||
did_finish.* = resp.tryEnd(buffer.items, total_size, resp.shouldCloseConnection());
|
||||
}
|
||||
|
||||
/// Sends a range of bytes from the blob
|
||||
fn renderBytesRange(this: *StaticRoute, resp: AnyResponse, range: ByteRange, did_finish: *bool) void {
|
||||
const blob = this.blob;
|
||||
const all_bytes = blob.slice();
|
||||
|
||||
// Ensure we don't read past the end of the array
|
||||
const start = @min(range.start, all_bytes.len);
|
||||
const end = @min(range.end + 1, all_bytes.len); // +1 because end is inclusive, but slice is exclusive
|
||||
|
||||
const bytes = all_bytes[start..end];
|
||||
const range_length = range.length();
|
||||
|
||||
did_finish.* = resp.tryEnd(bytes, range_length, resp.shouldCloseConnection());
|
||||
}
|
||||
179
test/js/bun/http/if-none-match.test.ts
Normal file
179
test/js/bun/http/if-none-match.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
|
||||
import { serve } from "bun";
|
||||
|
||||
describe("StaticRoute - If-None-Match", () => {
|
||||
const responseWithEtag = new Response("test response", {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"ETag": '"abc123"',
|
||||
},
|
||||
});
|
||||
|
||||
const responseWithWeakEtag = new Response("test response", {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"ETag": 'W/"abc123"',
|
||||
},
|
||||
});
|
||||
|
||||
const responseWithoutEtag = new Response("test response", {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
});
|
||||
|
||||
const routes = {
|
||||
"/strong-etag": responseWithEtag,
|
||||
"/weak-etag": responseWithWeakEtag,
|
||||
"/no-etag": responseWithoutEtag,
|
||||
};
|
||||
|
||||
let server;
|
||||
let handler = mock(() => new Response("fallback"));
|
||||
|
||||
beforeAll(() => {
|
||||
server = serve({
|
||||
static: routes,
|
||||
port: 0,
|
||||
fetch: handler,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop(true);
|
||||
});
|
||||
|
||||
describe("GET with If-None-Match", () => {
|
||||
it("returns 304 for matching ETag", async () => {
|
||||
const res = await fetch(`${server.url}strong-etag`, {
|
||||
headers: {
|
||||
"If-None-Match": '"abc123"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(304);
|
||||
expect(await res.text()).toBe("");
|
||||
expect(res.headers.get("ETag")).toBe('"abc123"');
|
||||
});
|
||||
|
||||
it("returns 304 for one matching ETag in a list", async () => {
|
||||
const res = await fetch(`${server.url}strong-etag`, {
|
||||
headers: {
|
||||
"If-None-Match": '"xyz", "abc123", "def456"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(304);
|
||||
expect(await res.text()).toBe("");
|
||||
});
|
||||
|
||||
it("returns 304 for If-None-Match: *", async () => {
|
||||
const res = await fetch(`${server.url}strong-etag`, {
|
||||
headers: {
|
||||
"If-None-Match": "*",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(304);
|
||||
expect(await res.text()).toBe("");
|
||||
});
|
||||
|
||||
it("returns 200 for non-matching ETag", async () => {
|
||||
const res = await fetch(`${server.url}strong-etag`, {
|
||||
headers: {
|
||||
"If-None-Match": '"xyz"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("test response");
|
||||
});
|
||||
|
||||
it("returns 200 for resource without ETag", async () => {
|
||||
const res = await fetch(`${server.url}no-etag`, {
|
||||
headers: {
|
||||
"If-None-Match": '"abc123"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("test response");
|
||||
});
|
||||
|
||||
it("handles weak/strong ETag matching (weak matching semantics)", async () => {
|
||||
// Weak ETag W/"abc123" should match strong ETag "abc123"
|
||||
const res1 = await fetch(`${server.url}strong-etag`, {
|
||||
headers: {
|
||||
"If-None-Match": 'W/"abc123"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res1.status).toBe(304);
|
||||
|
||||
// Strong ETag "abc123" should match weak ETag W/"abc123"
|
||||
const res2 = await fetch(`${server.url}weak-etag`, {
|
||||
headers: {
|
||||
"If-None-Match": '"abc123"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res2.status).toBe(304);
|
||||
});
|
||||
|
||||
it("ignores invalid If-None-Match header syntax", async () => {
|
||||
const res = await fetch(`${server.url}strong-etag`, {
|
||||
headers: {
|
||||
"If-None-Match": "abc123", // Missing quotes
|
||||
},
|
||||
});
|
||||
|
||||
// Invalid syntax should be ignored, resulting in 200 OK
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("test response");
|
||||
});
|
||||
|
||||
it("ignores If-None-Match on non-GET/HEAD methods", async () => {
|
||||
// Will result in 405 Method Not Allowed since StaticRoute only supports GET/HEAD
|
||||
const res = await fetch(`${server.url}strong-etag`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"If-None-Match": '"abc123"',
|
||||
},
|
||||
});
|
||||
|
||||
// Should be 405 Method Not Allowed
|
||||
expect(res.status).toBe(405);
|
||||
|
||||
// Point is it shouldn't be 304
|
||||
expect(res.status).not.toBe(304);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HEAD with If-None-Match", () => {
|
||||
it("returns 304 for matching ETag", async () => {
|
||||
const res = await fetch(`${server.url}strong-etag`, {
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
"If-None-Match": '"abc123"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(304);
|
||||
expect(await res.text()).toBe("");
|
||||
expect(res.headers.get("ETag")).toBe('"abc123"');
|
||||
});
|
||||
|
||||
it("returns 200 for non-matching ETag", async () => {
|
||||
const res = await fetch(`${server.url}strong-etag`, {
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
"If-None-Match": '"xyz"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("");
|
||||
expect(res.headers.get("Content-Length")).toBe("13"); // "test response" length
|
||||
});
|
||||
});
|
||||
});
|
||||
309
test/js/bun/http/range.test.ts
Normal file
309
test/js/bun/http/range.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
|
||||
import { serve } from "bun";
|
||||
|
||||
describe("StaticRoute - Range", () => {
|
||||
const testContent = "0123456789"; // 10 characters
|
||||
|
||||
// Create function to generate fresh responses for each test
|
||||
function createResponses() {
|
||||
return {
|
||||
"/test": new Response(testContent, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
}),
|
||||
|
||||
"/withEtag": new Response(testContent, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"ETag": '"abc123"',
|
||||
},
|
||||
}),
|
||||
|
||||
"/empty": new Response("", {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
let server;
|
||||
let handler = mock(() => new Response("fallback"));
|
||||
|
||||
beforeAll(() => {
|
||||
// Create a server with static routes to test the actual StaticRoute implementation
|
||||
server = serve({
|
||||
port: 0,
|
||||
static: createResponses(),
|
||||
fetch: handler,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop(true);
|
||||
});
|
||||
|
||||
describe("GET with Range header", () => {
|
||||
it("returns partial content for valid range", async () => {
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
headers: {
|
||||
"Range": "bytes=0-4",
|
||||
},
|
||||
});
|
||||
|
||||
// Test assertions
|
||||
expect(res.status).toBe(206);
|
||||
expect(await res.text()).toBe("01234");
|
||||
|
||||
// Verify required headers
|
||||
expect(res.headers.get("Content-Length")).toBe("5");
|
||||
expect(res.headers.get("Content-Range")).toBe("bytes 0-4/10");
|
||||
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
|
||||
// RFC 9110 requires these headers if they would have been in a 200 OK response
|
||||
expect(res.headers.has("Content-Type")).toBe(true);
|
||||
});
|
||||
|
||||
it("supports multiple ranges with multipart/byteranges response", async () => {
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
headers: {
|
||||
"Range": "bytes=0-2, 5-7",
|
||||
},
|
||||
});
|
||||
|
||||
// Test assertions
|
||||
expect(res.status).toBe(206);
|
||||
|
||||
// For multipart responses, the Content-Type should be multipart/byteranges
|
||||
const contentType = res.headers.get("Content-Type");
|
||||
expect(contentType?.startsWith("multipart/byteranges; boundary=")).toBe(true);
|
||||
|
||||
// Get the boundary from the Content-Type header
|
||||
const boundaryMatch = contentType?.match(/boundary=([^;]+)/);
|
||||
expect(boundaryMatch).not.toBeNull();
|
||||
|
||||
// Parse and verify the multipart response
|
||||
const body = await res.text();
|
||||
|
||||
// Verify multipart structure contains both ranges
|
||||
expect(body).toContain("Content-Range: bytes 0-2/10");
|
||||
expect(body).toContain("Content-Range: bytes 5-7/10");
|
||||
|
||||
// Verify actual content is present
|
||||
expect(body).toContain("012");
|
||||
expect(body).toContain("567");
|
||||
});
|
||||
|
||||
it("returns partial content for suffix range", async () => {
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
headers: {
|
||||
"Range": "bytes=-3",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(206);
|
||||
expect(await res.text()).toBe("789");
|
||||
expect(res.headers.get("Content-Length")).toBe("3");
|
||||
expect(res.headers.get("Content-Range")).toBe("bytes 7-9/10");
|
||||
});
|
||||
|
||||
it("returns partial content for open-ended range", async () => {
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
headers: {
|
||||
"Range": "bytes=7-",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(206);
|
||||
expect(await res.text()).toBe("789");
|
||||
expect(res.headers.get("Content-Length")).toBe("3");
|
||||
expect(res.headers.get("Content-Range")).toBe("bytes 7-9/10");
|
||||
});
|
||||
|
||||
it("returns 416 for unsatisfiable range", async () => {
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
headers: {
|
||||
"Range": "bytes=10-20",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(416);
|
||||
expect(res.headers.get("Content-Range")).toBe("bytes */10");
|
||||
expect(await res.text()).toBe("");
|
||||
});
|
||||
|
||||
it("returns 200 for invalid range syntax", async () => {
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
headers: {
|
||||
"Range": "bytes=5-2", // end < start
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe(testContent);
|
||||
// Verify Accept-Ranges header is present in 200 OK response
|
||||
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
});
|
||||
|
||||
it("returns 200 for unsupported range unit", async () => {
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
headers: {
|
||||
"Range": "pages=1-2", // Not 'bytes'
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe(testContent);
|
||||
});
|
||||
|
||||
it("correctly handles single byte ranges", async () => {
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
headers: {
|
||||
"Range": "bytes=4-4",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(206);
|
||||
expect(await res.text()).toBe("4");
|
||||
expect(res.headers.get("Content-Length")).toBe("1");
|
||||
expect(res.headers.get("Content-Range")).toBe("bytes 4-4/10");
|
||||
});
|
||||
|
||||
it("handles exact boundary ranges", async () => {
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
headers: {
|
||||
"Range": "bytes=0-9", // Full content range (0-9 is all 10 bytes)
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(206);
|
||||
expect(await res.text()).toBe(testContent);
|
||||
expect(res.headers.get("Content-Length")).toBe("10");
|
||||
expect(res.headers.get("Content-Range")).toBe("bytes 0-9/10");
|
||||
});
|
||||
|
||||
it("handles last byte range", async () => {
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
headers: {
|
||||
"Range": "bytes=9-9", // Just the last byte
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(206);
|
||||
expect(await res.text()).toBe("9");
|
||||
expect(res.headers.get("Content-Length")).toBe("1");
|
||||
expect(res.headers.get("Content-Range")).toBe("bytes 9-9/10");
|
||||
});
|
||||
|
||||
it("handles empty resources properly", async () => {
|
||||
const res = await fetch(`${server.url}empty`, {
|
||||
headers: {
|
||||
"Range": "bytes=0-10",
|
||||
},
|
||||
});
|
||||
|
||||
// For a 0-byte resource, any range is unsatisfiable
|
||||
expect(res.status).toBe(416);
|
||||
expect(res.headers.get("Content-Range")).toBe("bytes */0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Interaction with conditional requests", () => {
|
||||
it("ignores Range when If-None-Match matches", async () => {
|
||||
const res = await fetch(`${server.url}withEtag`, {
|
||||
headers: {
|
||||
"Range": "bytes=0-4",
|
||||
"If-None-Match": '"abc123"',
|
||||
},
|
||||
});
|
||||
|
||||
// If-None-Match has priority over Range
|
||||
expect(res.status).toBe(304);
|
||||
expect(await res.text()).toBe("");
|
||||
});
|
||||
|
||||
it("processes Range when If-None-Match doesn't match", async () => {
|
||||
const res = await fetch(`${server.url}withEtag`, {
|
||||
headers: {
|
||||
"Range": "bytes=0-4",
|
||||
"If-None-Match": '"xyz"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(206);
|
||||
expect(await res.text()).toBe("01234");
|
||||
});
|
||||
|
||||
it("ignores invalid If-None-Match header syntax", async () => {
|
||||
const res = await fetch(`${server.url}withEtag`, {
|
||||
headers: {
|
||||
"Range": "bytes=0-4",
|
||||
"If-None-Match": "abc123", // Missing quotes
|
||||
},
|
||||
});
|
||||
|
||||
// Invalid If-None-Match should be ignored, so Range is processed
|
||||
expect(res.status).toBe(206);
|
||||
expect(await res.text()).toBe("01234");
|
||||
});
|
||||
|
||||
it("processes Range when If-Range ETag matches resource ETag", async () => {
|
||||
const res = await fetch(`${server.url}withEtag`, {
|
||||
headers: {
|
||||
"Range": "bytes=0-4",
|
||||
"If-Range": '"abc123"',
|
||||
},
|
||||
});
|
||||
|
||||
// If-Range matches, so Range request is processed
|
||||
expect(res.status).toBe(206);
|
||||
expect(await res.text()).toBe("01234");
|
||||
});
|
||||
|
||||
it("ignores Range when If-Range ETag doesn't match resource ETag", async () => {
|
||||
const res = await fetch(`${server.url}withEtag`, {
|
||||
headers: {
|
||||
"Range": "bytes=0-4",
|
||||
"If-Range": '"mismatch"',
|
||||
},
|
||||
});
|
||||
|
||||
// If-Range doesn't match, so full response is sent
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe(testContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Method compatibility", () => {
|
||||
it("ignores Range on HEAD requests", async () => {
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
"Range": "bytes=0-4",
|
||||
},
|
||||
});
|
||||
|
||||
// Range should be ignored on HEAD requests
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Length")).toBe("10"); // full length
|
||||
});
|
||||
|
||||
it("ignores Range on non-GET/HEAD methods", async () => {
|
||||
// StaticRoute only handles GET/HEAD so we should get 405 Method Not Allowed
|
||||
// But the test verifies that Range doesn't get processed for these methods
|
||||
const res = await fetch(`${server.url}test`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Range": "bytes=0-4",
|
||||
},
|
||||
});
|
||||
|
||||
// Since we're using a static route, we should get 405 Method Not Allowed
|
||||
expect(res.status).toBe(405);
|
||||
|
||||
// The key point is that it shouldn't be a 206
|
||||
expect(res.status).not.toBe(206);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user