mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 22:01:47 +00:00
Compare commits
13 Commits
jarred/bar
...
claude/htt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca44414247 | ||
|
|
ad3359dd8c | ||
|
|
5cd677299d | ||
|
|
8e397874f1 | ||
|
|
3c96d08588 | ||
|
|
9129ff7c33 | ||
|
|
0579e7046c | ||
|
|
4187cef4b9 | ||
|
|
05bf4baf2b | ||
|
|
570d3a394a | ||
|
|
ba7a4d7048 | ||
|
|
f58ce945a4 | ||
|
|
4b6f043c78 |
@@ -16,6 +16,14 @@ pub fn writeStatus(comptime ssl: bool, resp_ptr: ?*uws.NewApp(ssl).Response, sta
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an IP address is localhost (used for compression detection)
|
||||
pub fn isLocalhost(addr: []const u8) bool {
|
||||
if (addr.len == 0) return false;
|
||||
return bun.strings.hasPrefixComptime(addr, "127.") or
|
||||
bun.strings.eqlComptime(addr, "::1") or
|
||||
bun.strings.eqlComptime(addr, "localhost");
|
||||
}
|
||||
|
||||
// TODO: rename to StaticBlobRoute? the html bundle is sometimes a static route
|
||||
pub const StaticRoute = @import("./server/StaticRoute.zig");
|
||||
pub const FileRoute = @import("./server/FileRoute.zig");
|
||||
@@ -565,6 +573,8 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
|
||||
|
||||
inspector_server_id: jsc.Debugger.DebuggerId = .init(0),
|
||||
|
||||
compression_config: ?*bun.http.CompressionConfig = null,
|
||||
|
||||
pub const doStop = host_fn.wrapInstanceMethod(ThisServer, "stopFromJS", false);
|
||||
|
||||
pub const dispose = host_fn.wrapInstanceMethod(ThisServer, "disposeFromJS", false);
|
||||
@@ -1618,6 +1628,10 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
|
||||
|
||||
this.config.deinit();
|
||||
|
||||
if (this.compression_config) |compression| {
|
||||
compression.deinit();
|
||||
}
|
||||
|
||||
this.on_clienterror.deinit();
|
||||
if (this.app) |app| {
|
||||
this.app = null;
|
||||
@@ -1671,6 +1685,19 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
|
||||
|
||||
server.request_pool_allocator = RequestContext.pool.?;
|
||||
|
||||
// Transfer compression config from ServerConfig to Server
|
||||
server.compression_config = if (config.compression_config_from_js) |comp_config| switch (comp_config) {
|
||||
.use_default => brk: {
|
||||
const default_config = bun.handleOom(bun.default_allocator.create(bun.http.CompressionConfig));
|
||||
default_config.* = bun.http.CompressionConfig.DEFAULT;
|
||||
break :brk default_config;
|
||||
},
|
||||
.config => |cfg| cfg,
|
||||
} else null;
|
||||
|
||||
// Clear it from config so deinit doesn't double-free
|
||||
config.compression_config_from_js = null;
|
||||
|
||||
if (comptime ssl_enabled) {
|
||||
analytics.Features.https_server += 1;
|
||||
} else {
|
||||
@@ -3122,6 +3149,16 @@ pub const AnyServer = struct {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn compressionConfig(this: AnyServer) ?*const bun.http.CompressionConfig {
|
||||
return switch (this.ptr.tag()) {
|
||||
Ptr.case(HTTPServer) => this.ptr.as(HTTPServer).compression_config,
|
||||
Ptr.case(HTTPSServer) => this.ptr.as(HTTPSServer).compression_config,
|
||||
Ptr.case(DebugHTTPServer) => this.ptr.as(DebugHTTPServer).compression_config,
|
||||
Ptr.case(DebugHTTPSServer) => this.ptr.as(DebugHTTPSServer).compression_config,
|
||||
else => bun.unreachablePanic("Invalid pointer tag", .{}),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn webSocketHandler(this: AnyServer) ?*WebSocketServerContext.Handler {
|
||||
const server_config: *ServerConfig = switch (this.ptr.tag()) {
|
||||
Ptr.case(HTTPServer) => &this.ptr.as(HTTPServer).config,
|
||||
|
||||
@@ -77,7 +77,6 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
|
||||
|
||||
additional_on_abort: ?AdditionalOnAbortCallback = null,
|
||||
|
||||
// TODO: support builtin compression
|
||||
const can_sendfile = !ssl_enabled and !Environment.isWindows;
|
||||
|
||||
pub fn setSignalAborted(this: *RequestContext, reason: bun.jsc.CommonAbortReason) void {
|
||||
@@ -2320,10 +2319,103 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
|
||||
writeHeaders(headers, ssl_enabled, this.resp);
|
||||
}
|
||||
|
||||
fn tryCompressResponse(
|
||||
this: *RequestContext,
|
||||
original_bytes: *const []const u8,
|
||||
compressed_data: *?[]u8,
|
||||
selected_encoding: *?bun.http.Encoding,
|
||||
) bool {
|
||||
const server = this.server orelse return false;
|
||||
const any_server = jsc.API.AnyServer.from(server);
|
||||
const config = any_server.compressionConfig() orelse return false;
|
||||
const resp = this.resp orelse return false;
|
||||
const req = this.req orelse return false;
|
||||
|
||||
if (config.disable_for_localhost) {
|
||||
if (req.header("host")) |host| {
|
||||
// Host header may include port (e.g., "localhost:3000")
|
||||
if (bun.strings.containsComptime(host, "localhost") or
|
||||
bun.strings.hasPrefixComptime(host, "127.") or
|
||||
bun.strings.containsComptime(host, "[::1]"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.getRemoteSocketInfo()) |addr| {
|
||||
if (isLocalhost(addr.ip)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
const accept_encoding = req.header("accept-encoding") orelse return false;
|
||||
const encoding = config.selectBestEncoding(accept_encoding) orelse return false;
|
||||
|
||||
if (original_bytes.len < config.threshold) return false;
|
||||
|
||||
const content_type_str = if (this.response_ptr) |response|
|
||||
if (response.getInitHeaders()) |headers|
|
||||
headers.fastGet(.ContentType)
|
||||
else
|
||||
null
|
||||
else
|
||||
null;
|
||||
|
||||
if (content_type_str) |ct| {
|
||||
if (!bun.http.Compressor.shouldCompressMIME(ct.slice())) return false;
|
||||
}
|
||||
|
||||
if (this.response_ptr) |response| {
|
||||
if (response.getInitHeaders()) |headers| {
|
||||
if (headers.fastHas(.ContentEncoding)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
const level = switch (encoding) {
|
||||
.brotli => if (config.brotli) |br| br.level else return false,
|
||||
.gzip => if (config.gzip) |gz| gz.level else return false,
|
||||
.zstd => if (config.zstd) |zs| zs.level else return false,
|
||||
.deflate => if (config.deflate) |df| df.level else return false,
|
||||
else => return false,
|
||||
};
|
||||
|
||||
const result = bun.http.Compressor.compress(
|
||||
this.allocator,
|
||||
original_bytes.*,
|
||||
encoding,
|
||||
level,
|
||||
);
|
||||
|
||||
if (result.len == 0) return false;
|
||||
|
||||
if (result.len >= original_bytes.len) {
|
||||
this.allocator.free(result);
|
||||
return false;
|
||||
}
|
||||
|
||||
compressed_data.* = result;
|
||||
selected_encoding.* = encoding;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn renderBytes(this: *RequestContext) void {
|
||||
// copy it to stack memory to prevent aliasing issues in release builds
|
||||
const blob = this.blob;
|
||||
const bytes = blob.slice();
|
||||
var bytes = blob.slice();
|
||||
|
||||
var compressed_data: ?[]u8 = null;
|
||||
var selected_encoding: ?bun.http.Encoding = null;
|
||||
defer if (compressed_data) |data| this.allocator.free(data);
|
||||
|
||||
const was_compressed = this.tryCompressResponse(&bytes, &compressed_data, &selected_encoding);
|
||||
if (was_compressed) {
|
||||
bytes = compressed_data.?;
|
||||
|
||||
if (this.resp) |resp| {
|
||||
resp.writeHeader("Content-Encoding", selected_encoding.?.toString());
|
||||
resp.writeHeader("Vary", "Accept-Encoding");
|
||||
}
|
||||
}
|
||||
|
||||
if (this.resp) |resp| {
|
||||
if (!resp.tryEnd(
|
||||
bytes,
|
||||
@@ -2670,6 +2762,7 @@ const logger = bun.logger;
|
||||
const uws = bun.uws;
|
||||
const Api = bun.schema.api;
|
||||
const writeStatus = bun.api.server.writeStatus;
|
||||
const isLocalhost = bun.api.server.isLocalhost;
|
||||
|
||||
const HTTP = bun.http;
|
||||
const MimeType = bun.http.MimeType;
|
||||
|
||||
@@ -63,6 +63,11 @@ user_routes_to_build: std.ArrayList(UserRouteBuilder) = std.ArrayList(UserRouteB
|
||||
|
||||
bake: ?bun.bake.UserOptions = null,
|
||||
|
||||
compression_config_from_js: ?union(enum) {
|
||||
use_default,
|
||||
config: *bun.http.CompressionConfig,
|
||||
} = null,
|
||||
|
||||
pub const DevelopmentOption = enum {
|
||||
development,
|
||||
production,
|
||||
@@ -277,6 +282,14 @@ pub fn deinit(this: *ServerConfig) void {
|
||||
bake.deinit();
|
||||
}
|
||||
|
||||
// Note: compression_config is transferred to server.compression_config
|
||||
// and cleaned up there, but we need to clean it if server creation failed
|
||||
if (this.compression_config_from_js) |comp| {
|
||||
if (comp == .config) {
|
||||
comp.config.deinit();
|
||||
}
|
||||
}
|
||||
|
||||
for (this.user_routes_to_build.items) |*builder| {
|
||||
builder.deinit();
|
||||
}
|
||||
@@ -960,6 +973,24 @@ pub fn fromJS(
|
||||
return error.JSError;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse compression config
|
||||
// Note: This is stored in the server, not ServerConfig, so we just parse and return it
|
||||
// It will be handled separately in serve() function
|
||||
args.compression_config_from_js = if (try arg.get(global, "compression")) |compression_val| blk: {
|
||||
if (compression_val.isUndefinedOrNull()) {
|
||||
break :blk null;
|
||||
}
|
||||
if (try bun.http.CompressionConfig.fromJS(global, compression_val)) |config| {
|
||||
break :blk .{ .config = config };
|
||||
} else {
|
||||
break :blk null;
|
||||
}
|
||||
} else blk: {
|
||||
break :blk null;
|
||||
};
|
||||
|
||||
if (global.hasException()) return error.JSError;
|
||||
} else {
|
||||
return global.throwInvalidArguments("Bun.serve expects an object", .{});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,27 @@ const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{});
|
||||
pub const ref = RefCount.ref;
|
||||
pub const deref = RefCount.deref;
|
||||
|
||||
pub const CompressedVariant = struct {
|
||||
data: []u8,
|
||||
encoding: bun.http.Encoding,
|
||||
|
||||
pub fn deinit(this: *CompressedVariant, allocator: std.mem.Allocator) void {
|
||||
allocator.free(this.data);
|
||||
}
|
||||
|
||||
pub fn memoryCost(this: *const CompressedVariant) usize {
|
||||
return this.data.len;
|
||||
}
|
||||
};
|
||||
|
||||
pub const CompressionFailed = struct {};
|
||||
|
||||
pub const CompressedSlot = union(enum) {
|
||||
none,
|
||||
failed: CompressionFailed,
|
||||
cached: CompressedVariant,
|
||||
};
|
||||
|
||||
// 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,
|
||||
@@ -19,6 +40,11 @@ headers: Headers = .{
|
||||
.allocator = bun.default_allocator,
|
||||
},
|
||||
|
||||
compressed_br: CompressedSlot = .none,
|
||||
compressed_gzip: CompressedSlot = .none,
|
||||
compressed_zstd: CompressedSlot = .none,
|
||||
compressed_deflate: CompressedSlot = .none,
|
||||
|
||||
pub const InitFromBytesOptions = struct {
|
||||
server: ?AnyServer,
|
||||
mime_type: ?*const bun.http.MimeType = null,
|
||||
@@ -35,7 +61,6 @@ pub fn initFromAnyBlob(blob: *const AnyBlob, options: InitFromBytesOptions) *Sta
|
||||
}
|
||||
}
|
||||
|
||||
// Generate ETag if not already present
|
||||
if (headers.get("etag") == null) {
|
||||
if (blob.slice().len > 0) {
|
||||
bun.handleOom(ETag.appendToHeaders(blob.slice(), &headers));
|
||||
@@ -64,6 +89,23 @@ fn deinit(this: *StaticRoute) void {
|
||||
this.blob.detach();
|
||||
this.headers.deinit();
|
||||
|
||||
switch (this.compressed_br) {
|
||||
.cached => |*variant| variant.deinit(bun.default_allocator),
|
||||
else => {},
|
||||
}
|
||||
switch (this.compressed_gzip) {
|
||||
.cached => |*variant| variant.deinit(bun.default_allocator),
|
||||
else => {},
|
||||
}
|
||||
switch (this.compressed_zstd) {
|
||||
.cached => |*variant| variant.deinit(bun.default_allocator),
|
||||
else => {},
|
||||
}
|
||||
switch (this.compressed_deflate) {
|
||||
.cached => |*variant| variant.deinit(bun.default_allocator),
|
||||
else => {},
|
||||
}
|
||||
|
||||
bun.destroy(this);
|
||||
}
|
||||
|
||||
@@ -82,8 +124,29 @@ pub fn clone(this: *StaticRoute, globalThis: *jsc.JSGlobalObject) !*StaticRoute
|
||||
});
|
||||
}
|
||||
|
||||
fn compressedMemoryCost(this: *const StaticRoute) usize {
|
||||
var cost: usize = 0;
|
||||
switch (this.compressed_br) {
|
||||
.cached => |variant| cost += variant.memoryCost(),
|
||||
else => {},
|
||||
}
|
||||
switch (this.compressed_gzip) {
|
||||
.cached => |variant| cost += variant.memoryCost(),
|
||||
else => {},
|
||||
}
|
||||
switch (this.compressed_zstd) {
|
||||
.cached => |variant| cost += variant.memoryCost(),
|
||||
else => {},
|
||||
}
|
||||
switch (this.compressed_deflate) {
|
||||
.cached => |variant| cost += variant.memoryCost(),
|
||||
else => {},
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
|
||||
pub fn memoryCost(this: *const StaticRoute) usize {
|
||||
return @sizeOf(StaticRoute) + this.blob.memoryCost() + this.headers.memoryCost();
|
||||
return @sizeOf(StaticRoute) + this.blob.memoryCost() + this.headers.memoryCost() + this.compressedMemoryCost();
|
||||
}
|
||||
|
||||
pub fn fromJS(globalThis: *jsc.JSGlobalObject, argument: jsc.JSValue) bun.JSError!?*StaticRoute {
|
||||
@@ -216,15 +279,125 @@ pub fn onRequest(this: *StaticRoute, req: *uws.Request, resp: AnyResponse) void
|
||||
}
|
||||
}
|
||||
|
||||
fn tryServeCompressed(this: *StaticRoute, req: *uws.Request, resp: AnyResponse) bool {
|
||||
const server = this.server orelse return false;
|
||||
const config = server.compressionConfig() orelse return false;
|
||||
|
||||
const accept_encoding = req.header("accept-encoding") orelse return false;
|
||||
if (accept_encoding.len == 0) return false;
|
||||
|
||||
if (config.disable_for_localhost) {
|
||||
if (req.header("host")) |host| {
|
||||
// Host header may include port (e.g., "localhost:3000")
|
||||
if (bun.strings.containsComptime(host, "localhost") or
|
||||
bun.strings.hasPrefixComptime(host, "127.") or
|
||||
bun.strings.containsComptime(host, "[::1]"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.getRemoteSocketInfo()) |addr| {
|
||||
if (isLocalhost(addr.ip)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cached_blob_size < config.threshold) return false;
|
||||
|
||||
const content_type = this.headers.getContentType();
|
||||
if (!bun.http.Compressor.shouldCompressMIME(content_type)) return false;
|
||||
|
||||
if (this.headers.get("Content-Encoding")) |_| return false;
|
||||
|
||||
const encoding = config.selectBestEncoding(accept_encoding) orelse return false;
|
||||
|
||||
const variant = this.getOrCreateCompressed(encoding, config) catch return false;
|
||||
|
||||
this.serveCompressed(variant, resp);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn getOrCreateCompressed(
|
||||
this: *StaticRoute,
|
||||
encoding: bun.http.Encoding,
|
||||
config: *const bun.http.CompressionConfig,
|
||||
) !*CompressedVariant {
|
||||
const variant_slot = switch (encoding) {
|
||||
.brotli => &this.compressed_br,
|
||||
.gzip => &this.compressed_gzip,
|
||||
.zstd => &this.compressed_zstd,
|
||||
.deflate => &this.compressed_deflate,
|
||||
else => return error.UnsupportedEncoding,
|
||||
};
|
||||
|
||||
switch (variant_slot.*) {
|
||||
.cached => |*cached| return cached,
|
||||
.failed => return error.CompressionFailed,
|
||||
.none => {},
|
||||
}
|
||||
|
||||
const level = switch (encoding) {
|
||||
.brotli => config.brotli.?.level,
|
||||
.gzip => config.gzip.?.level,
|
||||
.zstd => config.zstd.?.level,
|
||||
.deflate => config.deflate.?.level,
|
||||
else => unreachable,
|
||||
};
|
||||
|
||||
const compressed_data = bun.http.Compressor.compress(
|
||||
bun.default_allocator,
|
||||
this.blob.slice(),
|
||||
encoding,
|
||||
level,
|
||||
);
|
||||
|
||||
if (compressed_data.len == 0 or compressed_data.len >= this.blob.slice().len) {
|
||||
if (compressed_data.len > 0) bun.default_allocator.free(compressed_data);
|
||||
variant_slot.* = .{ .failed = .{} };
|
||||
return error.CompressionFailed;
|
||||
}
|
||||
|
||||
variant_slot.* = .{
|
||||
.cached = .{
|
||||
.data = compressed_data,
|
||||
.encoding = encoding,
|
||||
},
|
||||
};
|
||||
|
||||
return &variant_slot.cached;
|
||||
}
|
||||
|
||||
fn serveCompressed(this: *StaticRoute, variant: *CompressedVariant, resp: AnyResponse) void {
|
||||
this.ref();
|
||||
if (this.server) |server| {
|
||||
server.onPendingRequest();
|
||||
resp.timeout(server.config().idleTimeout);
|
||||
}
|
||||
|
||||
this.doWriteStatus(this.status_code, resp);
|
||||
this.doWriteHeadersExcluding(resp, &[_][]const u8{"content-length"});
|
||||
|
||||
resp.writeHeader("Vary", "Accept-Encoding");
|
||||
resp.writeHeader("Content-Encoding", variant.encoding.toString());
|
||||
|
||||
var content_length_buf: [64]u8 = undefined;
|
||||
const content_length = std.fmt.bufPrint(&content_length_buf, "{d}", .{variant.data.len}) catch unreachable;
|
||||
resp.writeHeader("Content-Length", content_length);
|
||||
|
||||
resp.end(variant.data, resp.shouldCloseConnection());
|
||||
this.onResponseComplete(resp);
|
||||
}
|
||||
|
||||
pub fn onGET(this: *StaticRoute, req: *uws.Request, resp: AnyResponse) void {
|
||||
// Check If-None-Match for GET requests with 200 status
|
||||
if (this.status_code == 200) {
|
||||
if (this.render304NotModifiedIfNoneMatch(req, resp)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with normal GET request handling
|
||||
if (this.tryServeCompressed(req, resp)) {
|
||||
return;
|
||||
}
|
||||
req.setYield(false);
|
||||
this.on(resp);
|
||||
}
|
||||
@@ -327,6 +500,32 @@ fn doWriteHeaders(this: *StaticRoute, resp: AnyResponse) void {
|
||||
}
|
||||
}
|
||||
|
||||
fn doWriteHeadersExcluding(this: *StaticRoute, resp: AnyResponse, exclude: []const []const u8) void {
|
||||
switch (resp) {
|
||||
inline .SSL, .TCP => |s| {
|
||||
const entries = this.headers.entries.slice();
|
||||
const names: []const api.StringPointer = entries.items(.name);
|
||||
const values: []const api.StringPointer = entries.items(.value);
|
||||
const buf = this.headers.buf.items;
|
||||
|
||||
for (names, values) |name, value| {
|
||||
const header_name = name.slice(buf);
|
||||
// Skip excluded headers (case-insensitive)
|
||||
var skip = false;
|
||||
for (exclude) |excluded| {
|
||||
if (bun.strings.eqlCaseInsensitiveASCIIICheckLength(header_name, excluded)) {
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!skip) {
|
||||
s.writeHeader(header_name, value.slice(buf));
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn renderBytes(this: *StaticRoute, resp: AnyResponse, did_finish: *bool) void {
|
||||
did_finish.* = this.onWritableBytes(0, resp);
|
||||
}
|
||||
@@ -387,6 +586,7 @@ const jsc = bun.jsc;
|
||||
const api = bun.schema.api;
|
||||
const AnyServer = jsc.API.AnyServer;
|
||||
const writeStatus = bun.api.server.writeStatus;
|
||||
const isLocalhost = bun.api.server.isLocalhost;
|
||||
const AnyBlob = jsc.WebCore.Blob.Any;
|
||||
|
||||
const ETag = bun.http.ETag;
|
||||
|
||||
@@ -2559,6 +2559,8 @@ pub const MimeType = @import("./http/MimeType.zig");
|
||||
pub const URLPath = @import("./http/URLPath.zig");
|
||||
pub const Encoding = @import("./http/Encoding.zig").Encoding;
|
||||
pub const Decompressor = @import("./http/Decompressor.zig").Decompressor;
|
||||
pub const CompressionConfig = @import("./http/CompressionConfig.zig").CompressionConfig;
|
||||
pub const Compressor = @import("./http/Compressor.zig").Compressor;
|
||||
pub const Signals = @import("./http/Signals.zig");
|
||||
pub const ThreadSafeStreamBuffer = @import("./http/ThreadSafeStreamBuffer.zig");
|
||||
pub const HTTPThread = @import("./http/HTTPThread.zig");
|
||||
|
||||
193
src/http/CompressionConfig.zig
Normal file
193
src/http/CompressionConfig.zig
Normal file
@@ -0,0 +1,193 @@
|
||||
const std = @import("std");
|
||||
const bun = @import("bun");
|
||||
const jsc = bun.jsc;
|
||||
const Encoding = @import("./Encoding.zig").Encoding;
|
||||
|
||||
pub const CompressionConfig = struct {
|
||||
pub const AlgorithmConfig = struct {
|
||||
level: u8,
|
||||
threshold: usize,
|
||||
|
||||
pub fn fromJS(globalThis: *jsc.JSGlobalObject, value: jsc.JSValue, comptime min_level: u8, comptime max_level: u8, default_level: u8) bun.JSError!AlgorithmConfig {
|
||||
if (value.isNumber()) {
|
||||
const level = try value.coerce(i32, globalThis);
|
||||
if (level < min_level or level > max_level) {
|
||||
return globalThis.throwInvalidArguments("compression level must be between {d} and {d}", .{ min_level, max_level });
|
||||
}
|
||||
return .{ .level = @intCast(level), .threshold = DEFAULT_THRESHOLD };
|
||||
}
|
||||
if (value.isObject()) {
|
||||
const level_val = try value.get(globalThis, "level") orelse return .{ .level = default_level, .threshold = DEFAULT_THRESHOLD };
|
||||
const level = try level_val.coerce(i32, globalThis);
|
||||
if (level < min_level or level > max_level) {
|
||||
return globalThis.throwInvalidArguments("compression level must be between {d} and {d}", .{ min_level, max_level });
|
||||
}
|
||||
|
||||
const threshold_val = try value.get(globalThis, "threshold");
|
||||
const threshold = if (threshold_val) |t| @as(usize, @intCast(try t.coerce(i32, globalThis))) else DEFAULT_THRESHOLD;
|
||||
|
||||
return .{ .level = @intCast(level), .threshold = threshold };
|
||||
}
|
||||
return .{ .level = default_level, .threshold = DEFAULT_THRESHOLD };
|
||||
}
|
||||
};
|
||||
|
||||
brotli: ?AlgorithmConfig,
|
||||
gzip: ?AlgorithmConfig,
|
||||
zstd: ?AlgorithmConfig,
|
||||
deflate: ?AlgorithmConfig,
|
||||
|
||||
threshold: usize,
|
||||
disable_for_localhost: bool,
|
||||
|
||||
pub const DEFAULT_THRESHOLD: usize = 1024;
|
||||
|
||||
pub const DEFAULT = CompressionConfig{
|
||||
.brotli = .{ .level = 4, .threshold = DEFAULT_THRESHOLD },
|
||||
.gzip = .{ .level = 6, .threshold = DEFAULT_THRESHOLD },
|
||||
.zstd = .{ .level = 3, .threshold = DEFAULT_THRESHOLD },
|
||||
.deflate = null,
|
||||
.threshold = DEFAULT_THRESHOLD,
|
||||
.disable_for_localhost = true,
|
||||
};
|
||||
|
||||
pub fn fromJS(globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) bun.JSError!?*CompressionConfig {
|
||||
if (value.isBoolean()) {
|
||||
if (!value.toBoolean()) return null;
|
||||
const config = bun.handleOom(bun.default_allocator.create(CompressionConfig));
|
||||
config.* = DEFAULT;
|
||||
return config;
|
||||
}
|
||||
|
||||
if (!value.isObject()) {
|
||||
return globalThis.throwInvalidArguments("compression must be a boolean or object", .{});
|
||||
}
|
||||
|
||||
const config = bun.handleOom(bun.default_allocator.create(CompressionConfig));
|
||||
errdefer bun.default_allocator.destroy(config);
|
||||
|
||||
config.* = DEFAULT;
|
||||
|
||||
if (try value.get(globalThis, "brotli")) |brotli_val| {
|
||||
if (brotli_val.isBoolean()) {
|
||||
if (!brotli_val.toBoolean()) config.brotli = null;
|
||||
} else {
|
||||
config.brotli = try AlgorithmConfig.fromJS(globalThis, brotli_val, 0, 11, 4);
|
||||
}
|
||||
}
|
||||
|
||||
if (try value.get(globalThis, "gzip")) |gzip_val| {
|
||||
if (gzip_val.isBoolean()) {
|
||||
if (!gzip_val.toBoolean()) config.gzip = null;
|
||||
} else {
|
||||
config.gzip = try AlgorithmConfig.fromJS(globalThis, gzip_val, 1, 9, 6);
|
||||
}
|
||||
}
|
||||
|
||||
if (try value.get(globalThis, "zstd")) |zstd_val| {
|
||||
if (zstd_val.isBoolean()) {
|
||||
if (!zstd_val.toBoolean()) config.zstd = null;
|
||||
} else {
|
||||
config.zstd = try AlgorithmConfig.fromJS(globalThis, zstd_val, 1, 22, 3);
|
||||
}
|
||||
}
|
||||
|
||||
if (try value.get(globalThis, "deflate")) |deflate_val| {
|
||||
if (deflate_val.isBoolean()) {
|
||||
if (!deflate_val.toBoolean()) config.deflate = null;
|
||||
} else {
|
||||
config.deflate = try AlgorithmConfig.fromJS(globalThis, deflate_val, 1, 9, 6);
|
||||
}
|
||||
}
|
||||
|
||||
if (try value.get(globalThis, "threshold")) |threshold_val| {
|
||||
if (threshold_val.isNumber()) {
|
||||
config.threshold = @intCast(try threshold_val.coerce(i32, globalThis));
|
||||
}
|
||||
}
|
||||
|
||||
if (try value.get(globalThis, "disableForLocalhost")) |disable_val| {
|
||||
if (disable_val.isBoolean()) {
|
||||
config.disable_for_localhost = disable_val.toBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
const Preference = struct {
|
||||
encoding: Encoding,
|
||||
quality: f32,
|
||||
};
|
||||
|
||||
pub fn selectBestEncoding(this: *const CompressionConfig, accept_encoding: []const u8) ?Encoding {
|
||||
var preferences: [8]Preference = undefined;
|
||||
var pref_count: usize = 0;
|
||||
|
||||
var iter = std.mem.splitScalar(u8, accept_encoding, ',');
|
||||
while (iter.next()) |token| {
|
||||
if (pref_count >= preferences.len) break;
|
||||
|
||||
const trimmed = std.mem.trim(u8, token, " \t");
|
||||
if (trimmed.len == 0) continue;
|
||||
|
||||
var quality: f32 = 1.0;
|
||||
var encoding_name = trimmed;
|
||||
|
||||
if (std.mem.indexOf(u8, trimmed, ";q=")) |q_pos| {
|
||||
encoding_name = std.mem.trim(u8, trimmed[0..q_pos], " \t");
|
||||
const q_str = std.mem.trim(u8, trimmed[q_pos + 3 ..], " \t");
|
||||
quality = std.fmt.parseFloat(f32, q_str) catch 1.0;
|
||||
} else if (std.mem.indexOf(u8, trimmed, "; q=")) |q_pos| {
|
||||
encoding_name = std.mem.trim(u8, trimmed[0..q_pos], " \t");
|
||||
const q_str = std.mem.trim(u8, trimmed[q_pos + 4 ..], " \t");
|
||||
quality = std.fmt.parseFloat(f32, q_str) catch 1.0;
|
||||
}
|
||||
|
||||
if (quality <= 0.0) continue;
|
||||
|
||||
const encoding: ?Encoding = if (bun.strings.eqlComptime(encoding_name, "br"))
|
||||
.brotli
|
||||
else if (bun.strings.eqlComptime(encoding_name, "gzip"))
|
||||
.gzip
|
||||
else if (bun.strings.eqlComptime(encoding_name, "zstd"))
|
||||
.zstd
|
||||
else if (bun.strings.eqlComptime(encoding_name, "deflate"))
|
||||
.deflate
|
||||
else if (bun.strings.eqlComptime(encoding_name, "identity"))
|
||||
.identity
|
||||
else if (bun.strings.eqlComptime(encoding_name, "*"))
|
||||
null
|
||||
else
|
||||
continue;
|
||||
|
||||
if (encoding) |enc| {
|
||||
preferences[pref_count] = .{ .encoding = enc, .quality = quality };
|
||||
pref_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
std.mem.sort(Preference, preferences[0..pref_count], {}, struct {
|
||||
fn lessThan(_: void, a: Preference, b: Preference) bool {
|
||||
return a.quality > b.quality;
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
for (preferences[0..pref_count]) |pref| {
|
||||
switch (pref.encoding) {
|
||||
.brotli => if (this.brotli != null) return .brotli,
|
||||
.zstd => if (this.zstd != null) return .zstd,
|
||||
.gzip => if (this.gzip != null) return .gzip,
|
||||
.deflate => if (this.deflate != null) return .deflate,
|
||||
.identity => return null,
|
||||
else => continue,
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn deinit(this: *CompressionConfig) void {
|
||||
bun.default_allocator.destroy(this);
|
||||
}
|
||||
};
|
||||
173
src/http/Compressor.zig
Normal file
173
src/http/Compressor.zig
Normal file
@@ -0,0 +1,173 @@
|
||||
const std = @import("std");
|
||||
const bun = @import("bun");
|
||||
const Encoding = @import("./Encoding.zig").Encoding;
|
||||
const Zlib = @import("../zlib.zig");
|
||||
const Brotli = bun.brotli;
|
||||
const zstd = bun.zstd;
|
||||
|
||||
pub const Compressor = struct {
|
||||
pub fn compress(
|
||||
allocator: std.mem.Allocator,
|
||||
data: []const u8,
|
||||
encoding: Encoding,
|
||||
level: u8,
|
||||
) []u8 {
|
||||
return switch (encoding) {
|
||||
.brotli => compressBrotli(allocator, data, level),
|
||||
.gzip => compressGzip(allocator, data, level),
|
||||
.zstd => compressZstd(allocator, data, level),
|
||||
.deflate => compressDeflate(allocator, data, level),
|
||||
else => &[_]u8{},
|
||||
};
|
||||
}
|
||||
|
||||
fn compressBrotli(allocator: std.mem.Allocator, data: []const u8, level: u8) []u8 {
|
||||
const max_output_size = Brotli.c.BrotliEncoderMaxCompressedSize(data.len);
|
||||
const output = allocator.alloc(u8, max_output_size) catch bun.outOfMemory();
|
||||
errdefer allocator.free(output);
|
||||
|
||||
var output_size = max_output_size;
|
||||
const result = Brotli.c.BrotliEncoderCompress(
|
||||
@intCast(level),
|
||||
Brotli.c.BROTLI_DEFAULT_WINDOW,
|
||||
.generic,
|
||||
data.len,
|
||||
data.ptr,
|
||||
&output_size,
|
||||
output.ptr,
|
||||
);
|
||||
|
||||
if (result == 0) {
|
||||
allocator.free(output);
|
||||
return &[_]u8{};
|
||||
}
|
||||
|
||||
return allocator.realloc(output, output_size) catch output[0..output_size];
|
||||
}
|
||||
|
||||
fn compressGzip(allocator: std.mem.Allocator, data: []const u8, level: u8) []u8 {
|
||||
return compressZlib(allocator, data, level, Zlib.MAX_WBITS | 16);
|
||||
}
|
||||
|
||||
fn compressDeflate(allocator: std.mem.Allocator, data: []const u8, level: u8) []u8 {
|
||||
return compressZlib(allocator, data, level, -Zlib.MAX_WBITS);
|
||||
}
|
||||
|
||||
fn compressZlib(allocator: std.mem.Allocator, data: []const u8, level: u8, window_bits: c_int) []u8 {
|
||||
var stream: Zlib.z_stream = undefined;
|
||||
@memset(std.mem.asBytes(&stream), 0);
|
||||
|
||||
const init_result = deflateInit2_(
|
||||
&stream,
|
||||
@intCast(level),
|
||||
Z_DEFLATED,
|
||||
window_bits,
|
||||
8,
|
||||
Z_DEFAULT_STRATEGY,
|
||||
Zlib.zlibVersion(),
|
||||
@sizeOf(Zlib.z_stream),
|
||||
);
|
||||
|
||||
if (init_result != .Ok) {
|
||||
return &[_]u8{};
|
||||
}
|
||||
defer _ = deflateEnd(&stream);
|
||||
|
||||
const max_output_size = deflateBound(&stream, data.len);
|
||||
const output = allocator.alloc(u8, max_output_size) catch bun.outOfMemory();
|
||||
errdefer allocator.free(output);
|
||||
|
||||
stream.next_in = data.ptr;
|
||||
stream.avail_in = @intCast(data.len);
|
||||
stream.next_out = output.ptr;
|
||||
stream.avail_out = @intCast(max_output_size);
|
||||
|
||||
const deflate_result = deflate(&stream, .Finish);
|
||||
if (deflate_result != .StreamEnd) {
|
||||
allocator.free(output);
|
||||
return &[_]u8{};
|
||||
}
|
||||
|
||||
const compressed_size = stream.total_out;
|
||||
return allocator.realloc(output, compressed_size) catch output[0..compressed_size];
|
||||
}
|
||||
|
||||
fn compressZstd(allocator: std.mem.Allocator, data: []const u8, level: u8) []u8 {
|
||||
const max_output_size = bun.zstd.compressBound(data.len);
|
||||
const output = allocator.alloc(u8, max_output_size) catch bun.outOfMemory();
|
||||
errdefer allocator.free(output);
|
||||
|
||||
const result = bun.zstd.compress(output, data, level);
|
||||
const compressed_size = switch (result) {
|
||||
.success => |size| size,
|
||||
.err => {
|
||||
allocator.free(output);
|
||||
return &[_]u8{};
|
||||
},
|
||||
};
|
||||
|
||||
// Shrink to actual size
|
||||
return allocator.realloc(output, compressed_size) catch output[0..compressed_size];
|
||||
}
|
||||
|
||||
pub fn shouldCompressMIME(content_type: ?[]const u8) bool {
|
||||
const mime = content_type orelse return true;
|
||||
|
||||
// Parse the MIME type to get its category
|
||||
const category = bun.http.MimeType.Category.init(mime);
|
||||
|
||||
// Check for categories that should always be compressed
|
||||
switch (category) {
|
||||
.text, .html, .css, .json, .javascript, .wasm, .font => return true,
|
||||
.image, .video, .audio => {
|
||||
// Special case: SVG is compressible even though it's an image
|
||||
if (bun.strings.hasPrefixComptime(mime, "image/svg+xml")) return true;
|
||||
return false;
|
||||
},
|
||||
.application => {
|
||||
// Check for XML-based formats (application/*+xml)
|
||||
if (bun.strings.containsComptime(mime, "+xml")) return true;
|
||||
// Check for JSON-based formats (application/*+json)
|
||||
if (bun.strings.containsComptime(mime, "+json")) return true;
|
||||
// Check for other XML formats
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/xml")) return true;
|
||||
|
||||
// Explicitly exclude pre-compressed formats
|
||||
if (bun.strings.containsComptime(mime, "zip")) return false;
|
||||
if (bun.strings.containsComptime(mime, "gzip")) return false;
|
||||
if (bun.strings.containsComptime(mime, "bzip")) return false;
|
||||
if (bun.strings.containsComptime(mime, "compress")) return false;
|
||||
if (bun.strings.containsComptime(mime, "zstd")) return false;
|
||||
if (bun.strings.containsComptime(mime, "rar")) return false;
|
||||
|
||||
// Exclude binary streams
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/octet-stream")) return false;
|
||||
|
||||
// Compress other application types by default
|
||||
return true;
|
||||
},
|
||||
else => return false,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Import external deflate function
|
||||
extern fn deflateEnd(strm: *Zlib.z_stream) Zlib.ReturnCode;
|
||||
extern fn deflateBound(strm: *Zlib.z_stream, sourceLen: c_ulong) c_ulong;
|
||||
extern fn deflate(strm: *Zlib.z_stream, flush: Zlib.FlushValue) Zlib.ReturnCode;
|
||||
extern fn deflateInit2_(
|
||||
strm: *Zlib.z_stream,
|
||||
level: c_int,
|
||||
method: c_int,
|
||||
windowBits: c_int,
|
||||
memLevel: c_int,
|
||||
strategy: c_int,
|
||||
version: [*:0]const u8,
|
||||
stream_size: c_int,
|
||||
) Zlib.ReturnCode;
|
||||
|
||||
const Z_DEFLATED = 8;
|
||||
const Z_DEFAULT_STRATEGY = 0;
|
||||
const Z_OK = 0;
|
||||
const Z_STREAM_END = 1;
|
||||
const Z_FINISH = 4;
|
||||
@@ -19,4 +19,15 @@ pub const Encoding = enum {
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn toString(this: Encoding) []const u8 {
|
||||
return switch (this) {
|
||||
.brotli => "br",
|
||||
.gzip => "gzip",
|
||||
.zstd => "zstd",
|
||||
.deflate => "deflate",
|
||||
.identity => "identity",
|
||||
.chunked => unreachable,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -477,6 +477,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort
|
||||
}
|
||||
this[serverSymbol] = Bun.serve<any>({
|
||||
idleTimeout: 0, // nodejs dont have a idleTimeout by default
|
||||
compression: false, // node:http doesn't support auto-compression
|
||||
tls,
|
||||
port,
|
||||
hostname: host,
|
||||
|
||||
376
test/js/bun/http/compression.test.ts
Normal file
376
test/js/bun/http/compression.test.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, tempDir } from "harness";
|
||||
|
||||
describe("HTTP Compression", () => {
|
||||
const ENCODINGS = ["br", "gzip", "zstd"] as const;
|
||||
const TEST_CONTENT = "Hello ".repeat(1000); // ~6KB compressible data
|
||||
|
||||
describe("Basic Functionality", () => {
|
||||
test("compression disabled by default", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return new Response(TEST_CONTENT, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": "br, gzip" },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBe(null);
|
||||
expect(await res.text()).toBe(TEST_CONTENT);
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("all encodings work correctly", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
compression: {
|
||||
brotli: 4,
|
||||
gzip: 6,
|
||||
zstd: 3,
|
||||
disableForLocalhost: false,
|
||||
},
|
||||
fetch() {
|
||||
return new Response(TEST_CONTENT, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for (const encoding of ENCODINGS) {
|
||||
const res = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": encoding },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBe(encoding);
|
||||
expect(res.headers.get("vary")).toBe("Accept-Encoding");
|
||||
expect(await res.text()).toBe(TEST_CONTENT);
|
||||
}
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("all variants share same ETag", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
compression: {
|
||||
brotli: 4,
|
||||
gzip: 6,
|
||||
zstd: 3,
|
||||
disableForLocalhost: false,
|
||||
},
|
||||
fetch() {
|
||||
return new Response(TEST_CONTENT, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"ETag": '"test-etag-123"',
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const etags = new Set<string>();
|
||||
|
||||
for (const encoding of ENCODINGS) {
|
||||
const res = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": encoding },
|
||||
});
|
||||
|
||||
const etag = res.headers.get("etag");
|
||||
expect(etag).toBeTruthy();
|
||||
etags.add(etag!);
|
||||
}
|
||||
|
||||
expect(etags.size).toBe(1);
|
||||
expect(Array.from(etags)[0]).toBe('"test-etag-123"');
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Configuration", () => {
|
||||
test("per-algorithm configuration", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
compression: {
|
||||
brotli: 6,
|
||||
gzip: false,
|
||||
zstd: 3,
|
||||
disableForLocalhost: false,
|
||||
},
|
||||
fetch() {
|
||||
return new Response(TEST_CONTENT, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const brRes = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": "br" },
|
||||
});
|
||||
expect(brRes.headers.get("content-encoding")).toBe("br");
|
||||
|
||||
const gzipRes = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": "gzip" },
|
||||
});
|
||||
expect(gzipRes.headers.get("content-encoding")).toBe(null);
|
||||
|
||||
const zstdRes = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": "zstd" },
|
||||
});
|
||||
expect(zstdRes.headers.get("content-encoding")).toBe("zstd");
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("threshold prevents small file compression", async () => {
|
||||
const smallContent = "tiny";
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
compression: {
|
||||
brotli: 4,
|
||||
threshold: 1000,
|
||||
},
|
||||
fetch() {
|
||||
return new Response(smallContent, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": "br" },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBe(null);
|
||||
expect(await res.text()).toBe(smallContent);
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("localhost detection", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
compression: {
|
||||
brotli: 4,
|
||||
disableForLocalhost: true,
|
||||
},
|
||||
fetch() {
|
||||
return new Response(TEST_CONTENT, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${server.port}`, {
|
||||
headers: { "Accept-Encoding": "br" },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBe(null);
|
||||
expect(await res.text()).toBe(TEST_CONTENT);
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content Filtering", () => {
|
||||
test("skips incompressible MIME types", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
compression: true,
|
||||
fetch() {
|
||||
return new Response(TEST_CONTENT, {
|
||||
headers: { "Content-Type": "image/jpeg" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": "br, gzip" },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBe(null);
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("skips already-encoded responses", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
compression: true,
|
||||
fetch() {
|
||||
return new Response(TEST_CONTENT, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"Content-Encoding": "identity",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": "br" },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBe("identity");
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content Negotiation", () => {
|
||||
test("quality value negotiation", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
compression: {
|
||||
brotli: 4,
|
||||
gzip: 6,
|
||||
zstd: 3,
|
||||
disableForLocalhost: false,
|
||||
},
|
||||
fetch() {
|
||||
return new Response(TEST_CONTENT, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": "br;q=0.5, gzip;q=1.0" },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBe("gzip");
|
||||
expect(await res.text()).toBe(TEST_CONTENT);
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dynamic Routes", () => {
|
||||
test("on-demand compression", async () => {
|
||||
let requestCount = 0;
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
compression: {
|
||||
brotli: 4,
|
||||
gzip: 6,
|
||||
zstd: 3,
|
||||
disableForLocalhost: false,
|
||||
},
|
||||
async fetch() {
|
||||
requestCount++;
|
||||
return new Response(`Request #${requestCount}: ${TEST_CONTENT}`, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for (const encoding of ENCODINGS) {
|
||||
const res = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": encoding },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBe(encoding);
|
||||
expect(res.headers.get("vary")).toBe("Accept-Encoding");
|
||||
|
||||
const text = await res.text();
|
||||
expect(text).toContain("Request #");
|
||||
expect(text).toContain(TEST_CONTENT);
|
||||
}
|
||||
|
||||
expect(requestCount).toBe(ENCODINGS.length);
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("no caching between requests", async () => {
|
||||
let requestCount = 0;
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
compression: true,
|
||||
fetch() {
|
||||
requestCount++;
|
||||
return new Response(`Count: ${requestCount}`, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const res1 = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": "br" },
|
||||
});
|
||||
expect(await res1.text()).toBe("Count: 1");
|
||||
|
||||
const res2 = await fetch(`http://localhost:${server.port}`, {
|
||||
headers: { "Accept-Encoding": "br" },
|
||||
});
|
||||
expect(await res2.text()).toBe("Count: 2");
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Node.js Compatibility", () => {
|
||||
test("node:http never auto-compresses", async () => {
|
||||
using dir = tempDir("node-http-compression", {
|
||||
"server.js": `
|
||||
const http = require("http");
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end("${"Hello ".repeat(1000)}");
|
||||
});
|
||||
server.listen(0, () => {
|
||||
console.log(server.address().port);
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
const proc = Bun.spawn({
|
||||
cmd: [bunExe(), "server.js"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const reader = proc.stdout.getReader();
|
||||
const { value } = await reader.read();
|
||||
const port = parseInt(new TextDecoder().decode(value).trim());
|
||||
|
||||
const res = await fetch(`http://localhost:${port}`, {
|
||||
headers: { "Accept-Encoding": "br, gzip" },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBe(null);
|
||||
|
||||
proc.kill();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user