mirror of
https://github.com/oven-sh/bun
synced 2026-02-08 09:58:55 +00:00
Compare commits
6 Commits
dylan/pyth
...
claude/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2abfa2235 | ||
|
|
c2be0f801c | ||
|
|
6f75941f2e | ||
|
|
2b6b6ce2cb | ||
|
|
4014f88efe | ||
|
|
c6188c6f9f |
@@ -565,6 +565,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 +1620,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 +1677,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 +3141,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,
|
||||
|
||||
@@ -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,28 @@ 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()) {
|
||||
// undefined/null: use default (compression ON)
|
||||
break :blk if (@import("../../../http/CompressionConfig.zig").COMPRESSION_ENABLED_BY_DEFAULT) .use_default else null;
|
||||
}
|
||||
// Parse from JS (handles true/false/object)
|
||||
if (try bun.http.CompressionConfig.fromJS(global, compression_val)) |config| {
|
||||
break :blk .{ .config = config };
|
||||
} else {
|
||||
// false: explicitly disabled
|
||||
break :blk null;
|
||||
}
|
||||
} else blk: {
|
||||
// No compression option: use default
|
||||
break :blk if (@import("../../../http/CompressionConfig.zig").COMPRESSION_ENABLED_BY_DEFAULT) .use_default else null;
|
||||
};
|
||||
|
||||
if (global.hasException()) return error.JSError;
|
||||
} else {
|
||||
return global.throwInvalidArguments("Bun.serve expects an object", .{});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,18 @@ const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{});
|
||||
pub const ref = RefCount.ref;
|
||||
pub const deref = RefCount.deref;
|
||||
|
||||
/// Compressed variant of the static response
|
||||
pub const CompressedVariant = struct {
|
||||
data: []u8,
|
||||
etag: []const u8,
|
||||
encoding: bun.http.Encoding,
|
||||
|
||||
pub fn deinit(this: *CompressedVariant, allocator: std.mem.Allocator) void {
|
||||
allocator.free(this.data);
|
||||
allocator.free(this.etag);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 +31,12 @@ headers: Headers = .{
|
||||
.allocator = bun.default_allocator,
|
||||
},
|
||||
|
||||
// Lazy-initialized compressed variants (cached)
|
||||
compressed_br: ?CompressedVariant = null,
|
||||
compressed_gzip: ?CompressedVariant = null,
|
||||
compressed_zstd: ?CompressedVariant = null,
|
||||
compressed_deflate: ?CompressedVariant = null,
|
||||
|
||||
pub const InitFromBytesOptions = struct {
|
||||
server: ?AnyServer,
|
||||
mime_type: ?*const bun.http.MimeType = null,
|
||||
@@ -64,6 +82,12 @@ fn deinit(this: *StaticRoute) void {
|
||||
this.blob.detach();
|
||||
this.headers.deinit();
|
||||
|
||||
// Clean up compressed variants
|
||||
if (this.compressed_br) |*variant| variant.deinit(bun.default_allocator);
|
||||
if (this.compressed_gzip) |*variant| variant.deinit(bun.default_allocator);
|
||||
if (this.compressed_zstd) |*variant| variant.deinit(bun.default_allocator);
|
||||
if (this.compressed_deflate) |*variant| variant.deinit(bun.default_allocator);
|
||||
|
||||
bun.destroy(this);
|
||||
}
|
||||
|
||||
@@ -83,7 +107,15 @@ pub fn clone(this: *StaticRoute, globalThis: *jsc.JSGlobalObject) !*StaticRoute
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Add compressed variant costs
|
||||
if (this.compressed_br) |variant| cost += variant.data.len + variant.etag.len;
|
||||
if (this.compressed_gzip) |variant| cost += variant.data.len + variant.etag.len;
|
||||
if (this.compressed_zstd) |variant| cost += variant.data.len + variant.etag.len;
|
||||
if (this.compressed_deflate) |variant| cost += variant.data.len + variant.etag.len;
|
||||
|
||||
return cost;
|
||||
}
|
||||
|
||||
pub fn fromJS(globalThis: *jsc.JSGlobalObject, argument: jsc.JSValue) bun.JSError!?*StaticRoute {
|
||||
@@ -216,6 +248,153 @@ pub fn onRequest(this: *StaticRoute, req: *uws.Request, resp: AnyResponse) void
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to serve a compressed variant if compression is enabled and conditions are met
|
||||
///
|
||||
/// NOTE: Streaming responses are NOT handled here - they're rejected at fromJS() line 160
|
||||
/// and go through RequestContext instead. This only compresses fully buffered static responses.
|
||||
fn tryServeCompressed(this: *StaticRoute, req: *uws.Request, resp: AnyResponse) bool {
|
||||
const server = this.server orelse return false;
|
||||
const config = server.compressionConfig() orelse return false;
|
||||
|
||||
// Skip if caching is disabled
|
||||
if (config.cache == null) return false;
|
||||
|
||||
// Check Accept-Encoding header (must be lowercase for uws)
|
||||
const accept_encoding = req.header("accept-encoding") orelse return false;
|
||||
if (accept_encoding.len == 0) return false;
|
||||
|
||||
// Skip if localhost and configured to disable
|
||||
if (config.disable_for_localhost) {
|
||||
if (resp.getRemoteSocketInfo()) |addr| {
|
||||
if (isLocalhost(addr.ip)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if too small
|
||||
if (this.cached_blob_size < config.threshold) return false;
|
||||
|
||||
// Skip if wrong MIME type
|
||||
const content_type = this.headers.getContentType();
|
||||
if (!bun.http.Compressor.shouldCompressMIME(content_type)) return false;
|
||||
|
||||
// Skip if already has Content-Encoding
|
||||
if (this.headers.get("Content-Encoding")) |_| return false;
|
||||
|
||||
// Select best encoding
|
||||
const encoding = config.selectBestEncoding(accept_encoding) orelse return false;
|
||||
|
||||
// Get or create compressed variant
|
||||
const variant = this.getOrCreateCompressed(encoding, config) catch return false;
|
||||
|
||||
// Serve compressed response
|
||||
this.serveCompressed(variant, resp);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Get or create a compressed variant (lazy compression with caching)
|
||||
fn getOrCreateCompressed(
|
||||
this: *StaticRoute,
|
||||
encoding: bun.http.Encoding,
|
||||
config: *const bun.http.CompressionConfig,
|
||||
) !*CompressedVariant {
|
||||
// Get pointer to the variant slot
|
||||
const variant_slot: *?CompressedVariant = switch (encoding) {
|
||||
.brotli => &this.compressed_br,
|
||||
.gzip => &this.compressed_gzip,
|
||||
.zstd => &this.compressed_zstd,
|
||||
.deflate => &this.compressed_deflate,
|
||||
else => return error.UnsupportedEncoding,
|
||||
};
|
||||
|
||||
// Return cached if exists
|
||||
if (variant_slot.*) |*cached| {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Compress the blob
|
||||
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,
|
||||
);
|
||||
|
||||
// Check if compression failed (empty slice returned)
|
||||
if (compressed_data.len == 0) {
|
||||
return error.CompressionFailed;
|
||||
}
|
||||
|
||||
// Generate ETag for compressed variant by hashing the compressed data
|
||||
const compressed_etag = generateCompressedETag(compressed_data);
|
||||
|
||||
// Store in cache
|
||||
variant_slot.* = .{
|
||||
.data = compressed_data,
|
||||
.etag = compressed_etag,
|
||||
.encoding = encoding,
|
||||
};
|
||||
|
||||
return &variant_slot.*.?;
|
||||
}
|
||||
|
||||
/// Generate ETag for compressed variant by hashing the compressed bytes
|
||||
/// This ensures the ETag accurately represents the compressed content
|
||||
fn generateCompressedETag(compressed_data: []const u8) []const u8 {
|
||||
const hash = std.hash.XxHash64.hash(0, compressed_data);
|
||||
var etag_buf: [40]u8 = undefined;
|
||||
const etag_str = std.fmt.bufPrint(&etag_buf, "\"{}\"", .{bun.fmt.hexIntLower(hash)}) catch unreachable;
|
||||
return bun.handleOom(bun.default_allocator.dupe(u8, etag_str));
|
||||
}
|
||||
|
||||
/// Serve a compressed response
|
||||
fn serveCompressed(this: *StaticRoute, variant: *CompressedVariant, resp: AnyResponse) void {
|
||||
this.ref();
|
||||
if (this.server) |server| {
|
||||
server.onPendingRequest();
|
||||
resp.timeout(server.config().idleTimeout);
|
||||
}
|
||||
|
||||
// Write status
|
||||
this.doWriteStatus(this.status_code, resp);
|
||||
|
||||
// Write headers, but skip ETag and Content-Length (we'll set them for compressed data)
|
||||
this.doWriteHeadersExcluding(resp, &[_][]const u8{ "etag", "content-length" });
|
||||
|
||||
// Add Vary: Accept-Encoding (critical for caching!)
|
||||
resp.writeHeader("Vary", "Accept-Encoding");
|
||||
|
||||
// Set Content-Encoding
|
||||
resp.writeHeader("Content-Encoding", variant.encoding.toString());
|
||||
|
||||
// Set ETag for compressed variant
|
||||
resp.writeHeader("ETag", variant.etag);
|
||||
|
||||
// Set Content-Length for compressed data
|
||||
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);
|
||||
|
||||
// Send body
|
||||
resp.end(variant.data, resp.shouldCloseConnection());
|
||||
this.onResponseComplete(resp);
|
||||
}
|
||||
|
||||
/// Check if remote address is localhost
|
||||
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");
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -224,6 +403,11 @@ pub fn onGET(this: *StaticRoute, req: *uws.Request, resp: AnyResponse) void {
|
||||
}
|
||||
}
|
||||
|
||||
// Try compression if configured
|
||||
if (this.tryServeCompressed(req, resp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue with normal GET request handling
|
||||
req.setYield(false);
|
||||
this.on(resp);
|
||||
@@ -327,6 +511,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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
364
src/http/CompressionConfig.zig
Normal file
364
src/http/CompressionConfig.zig
Normal file
@@ -0,0 +1,364 @@
|
||||
const std = @import("std");
|
||||
const bun = @import("bun");
|
||||
const jsc = bun.jsc;
|
||||
const Encoding = @import("./Encoding.zig").Encoding;
|
||||
|
||||
/// EASY DEFAULT TOGGLE: Change this to switch compression on/off by default
|
||||
/// NOTE: Compression is OPT-IN because it requires caching for performance.
|
||||
/// Enable explicitly with `compression: true` or `compression: { ... }` in Bun.serve()
|
||||
pub const COMPRESSION_ENABLED_BY_DEFAULT = false;
|
||||
|
||||
/// Compression Configuration for Bun.serve()
|
||||
///
|
||||
/// ## Current Implementation:
|
||||
/// - **Static routes only** - Only compresses Response objects defined in routes
|
||||
/// - **Lazy caching** - First request compresses and caches, subsequent requests serve cached version
|
||||
/// - **Per-encoding cache** - Stores separate compressed variant for EACH encoding client requests
|
||||
/// - **Memory cost** - Each static route stores original + up to 4 compressed variants
|
||||
/// - Small files (< 10KB): negligible extra memory (~200 bytes total for all variants)
|
||||
/// - Large files (1MB+): significant extra memory (~300-400KB for all variants)
|
||||
///
|
||||
/// ## Memory Implications:
|
||||
/// Static routes already cache the original file data. This adds compressed variants:
|
||||
/// - If you have 100 static routes with 1MB files = ~40MB extra for compression cache
|
||||
/// - Only caches variants that clients actually request (lazy)
|
||||
/// - Compression often makes files smaller, but we store BOTH original and compressed
|
||||
///
|
||||
/// ## Not Supported (Yet):
|
||||
/// - **Dynamic routes** - Responses from fetch() handlers (would need LRU cache with TTL)
|
||||
/// - **Streaming responses** - ReadableStream bodies are rejected from static routes (see StaticRoute.zig:160)
|
||||
/// - **Cache enforcement** - Cache config exists but limits not enforced yet (TODO)
|
||||
/// - cache.maxSize, cache.ttl, cache.minEntrySize, cache.maxEntrySize are parsed but not checked
|
||||
/// - Setting cache: false disables caching immediately
|
||||
/// - --smol mode uses smaller defaults which will matter once enforcement is added
|
||||
/// - **Per-route control** - Can only enable/disable globally or per-algorithm
|
||||
///
|
||||
/// ## Usage:
|
||||
/// ```js
|
||||
/// Bun.serve({
|
||||
/// compression: true, // Use defaults (br=4, gzip=6, zstd=3, 50MB cache, 24h TTL)
|
||||
/// compression: {
|
||||
/// brotli: 6,
|
||||
/// gzip: false, // Disable specific algorithm
|
||||
/// cache: false, // Disable caching entirely (compress on-demand)
|
||||
/// cache: {
|
||||
/// maxSize: 100 * 1024 * 1024, // 100MB total cache
|
||||
/// ttl: 3600, // 1 hour (seconds)
|
||||
/// minEntrySize: 512, // Don't cache < 512 bytes
|
||||
/// maxEntrySize: 5 * 1024 * 1024, // Don't cache > 5MB
|
||||
/// }
|
||||
/// },
|
||||
/// compression: false, // Disable (default)
|
||||
/// })
|
||||
/// ```
|
||||
///
|
||||
/// ## --smol Mode:
|
||||
/// When `bun --smol` is used, compression defaults to more conservative limits:
|
||||
/// - maxSize: 5MB (vs 50MB normal)
|
||||
/// - ttl: 1 hour (vs 24 hours normal)
|
||||
/// - maxEntrySize: 1MB (vs 10MB normal)
|
||||
pub const CompressionConfig = struct {
|
||||
pub const CacheConfig = struct {
|
||||
/// Maximum total size of all cached compressed variants (bytes)
|
||||
max_size: usize,
|
||||
/// Time-to-live for cached variants (milliseconds), 0 = infinite
|
||||
ttl_ms: u64,
|
||||
/// Minimum size of entry to cache (bytes)
|
||||
min_entry_size: usize,
|
||||
/// Maximum size of single entry to cache (bytes)
|
||||
max_entry_size: usize,
|
||||
|
||||
pub const DEFAULT = CacheConfig{
|
||||
.max_size = 50 * 1024 * 1024, // 50MB total cache
|
||||
.ttl_ms = 24 * 60 * 60 * 1000, // 24 hours
|
||||
.min_entry_size = 128, // Don't cache tiny files
|
||||
.max_entry_size = 10 * 1024 * 1024, // Don't cache > 10MB
|
||||
};
|
||||
|
||||
pub const SMOL = CacheConfig{
|
||||
.max_size = 5 * 1024 * 1024, // 5MB total cache for --smol
|
||||
.ttl_ms = 60 * 60 * 1000, // 1 hour
|
||||
.min_entry_size = 512, // Higher threshold
|
||||
.max_entry_size = 1 * 1024 * 1024, // Max 1MB per entry
|
||||
};
|
||||
|
||||
pub fn fromJS(globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) bun.JSError!CacheConfig {
|
||||
var config = CacheConfig.DEFAULT;
|
||||
|
||||
if (try value.getOptional(globalThis, "maxSize", i32)) |max_size| {
|
||||
config.max_size = @intCast(@max(0, max_size));
|
||||
}
|
||||
if (try value.getOptional(globalThis, "ttl", i32)) |ttl_seconds| {
|
||||
config.ttl_ms = @intCast(@max(0, ttl_seconds) * 1000);
|
||||
}
|
||||
if (try value.getOptional(globalThis, "minEntrySize", i32)) |min_size| {
|
||||
config.min_entry_size = @intCast(@max(0, min_size));
|
||||
}
|
||||
if (try value.getOptional(globalThis, "maxEntrySize", i32)) |max_size| {
|
||||
config.max_entry_size = @intCast(@max(0, max_size));
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
cache: ?CacheConfig,
|
||||
|
||||
pub const DEFAULT_THRESHOLD: usize = 1024;
|
||||
|
||||
/// Default compression configuration - modify these values to change defaults
|
||||
pub const DEFAULT = CompressionConfig{
|
||||
.brotli = .{ .level = 4, .threshold = DEFAULT_THRESHOLD }, // Sweet spot for speed/compression
|
||||
.gzip = .{ .level = 6, .threshold = DEFAULT_THRESHOLD }, // Standard default
|
||||
.zstd = .{ .level = 3, .threshold = DEFAULT_THRESHOLD }, // Fast default
|
||||
.deflate = null, // Disabled by default (obsolete)
|
||||
.threshold = DEFAULT_THRESHOLD,
|
||||
.disable_for_localhost = true,
|
||||
.cache = CacheConfig.DEFAULT,
|
||||
};
|
||||
|
||||
/// Parse compression config from JavaScript
|
||||
/// Supports:
|
||||
/// - true: use defaults
|
||||
/// - false: disable compression (returns null)
|
||||
/// - { brotli: 4, gzip: 6, zstd: false, ... }: custom config
|
||||
pub fn fromJS(globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) bun.JSError!?*CompressionConfig {
|
||||
// Check if --smol mode is enabled
|
||||
const is_smol = globalThis.bunVM().smol;
|
||||
|
||||
if (value.isBoolean()) {
|
||||
if (!value.toBoolean()) {
|
||||
// compression: false -> return null to indicate disabled
|
||||
return null;
|
||||
}
|
||||
// compression: true -> use defaults (smol-aware)
|
||||
const config = bun.handleOom(bun.default_allocator.create(CompressionConfig));
|
||||
config.* = DEFAULT;
|
||||
if (is_smol and config.cache != null) {
|
||||
config.cache = CacheConfig.SMOL;
|
||||
}
|
||||
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);
|
||||
|
||||
// Start with defaults (smol-aware)
|
||||
config.* = DEFAULT;
|
||||
if (is_smol and config.cache != null) {
|
||||
config.cache = CacheConfig.SMOL;
|
||||
}
|
||||
|
||||
// Parse brotli config (supports false, number, or object)
|
||||
if (try value.get(globalThis, "brotli")) |brotli_val| {
|
||||
if (brotli_val.isBoolean()) {
|
||||
if (!brotli_val.toBoolean()) {
|
||||
config.brotli = null; // Explicitly disabled
|
||||
}
|
||||
// If true, keep default
|
||||
} else {
|
||||
config.brotli = try AlgorithmConfig.fromJS(globalThis, brotli_val, 0, 11, 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse gzip config
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse zstd config
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse deflate config
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse threshold
|
||||
if (try value.get(globalThis, "threshold")) |threshold_val| {
|
||||
if (threshold_val.isNumber()) {
|
||||
config.threshold = @intCast(try threshold_val.coerce(i32, globalThis));
|
||||
}
|
||||
}
|
||||
|
||||
// Parse disableForLocalhost
|
||||
if (try value.get(globalThis, "disableForLocalhost")) |disable_val| {
|
||||
if (disable_val.isBoolean()) {
|
||||
config.disable_for_localhost = disable_val.toBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse cache config
|
||||
if (try value.get(globalThis, "cache")) |cache_val| {
|
||||
if (cache_val.isBoolean()) {
|
||||
if (!cache_val.toBoolean()) {
|
||||
config.cache = null; // false = disable caching
|
||||
}
|
||||
} else if (cache_val.isObject()) {
|
||||
config.cache = try CacheConfig.fromJS(globalThis, cache_val);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
const Preference = struct {
|
||||
encoding: Encoding,
|
||||
quality: f32,
|
||||
};
|
||||
|
||||
/// Select best encoding based on Accept-Encoding header and available config
|
||||
/// Returns null if no compression should be used
|
||||
pub fn selectBestEncoding(this: *const CompressionConfig, accept_encoding: []const u8) ?Encoding {
|
||||
var preferences: [8]Preference = undefined;
|
||||
var pref_count: usize = 0;
|
||||
|
||||
// Parse Accept-Encoding header
|
||||
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;
|
||||
|
||||
// Parse quality value
|
||||
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;
|
||||
}
|
||||
|
||||
// Skip if quality is 0 (explicitly disabled)
|
||||
if (quality <= 0.0) continue;
|
||||
|
||||
// Map to encoding enum
|
||||
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 // wildcard
|
||||
else
|
||||
continue; // unknown encoding
|
||||
|
||||
if (encoding) |enc| {
|
||||
preferences[pref_count] = .{ .encoding = enc, .quality = quality };
|
||||
pref_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by quality (descending)
|
||||
std.mem.sort(Preference, preferences[0..pref_count], {}, struct {
|
||||
fn lessThan(_: void, a: Preference, b: Preference) bool {
|
||||
return a.quality > b.quality;
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
// Select first available encoding that's enabled
|
||||
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, // Client wants no compression
|
||||
else => continue,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use server preference if no quality specified or all equal
|
||||
if (pref_count == 0 or allQualitiesEqual(preferences[0..pref_count])) {
|
||||
if (this.brotli != null) return .brotli;
|
||||
if (this.zstd != null) return .zstd;
|
||||
if (this.gzip != null) return .gzip;
|
||||
if (this.deflate != null) return .deflate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn allQualitiesEqual(prefs: []const Preference) bool {
|
||||
if (prefs.len == 0) return true;
|
||||
const first = prefs[0].quality;
|
||||
for (prefs[1..]) |p| {
|
||||
if (p.quality != first) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn deinit(this: *CompressionConfig) void {
|
||||
bun.default_allocator.destroy(this);
|
||||
}
|
||||
};
|
||||
179
src/http/Compressor.zig
Normal file
179
src/http/Compressor.zig
Normal file
@@ -0,0 +1,179 @@
|
||||
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 {
|
||||
/// Compress data using the specified encoding and level
|
||||
/// Returns empty slice on error (caller should check length)
|
||||
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{}, // Unsupported encoding
|
||||
};
|
||||
}
|
||||
|
||||
fn compressBrotli(allocator: std.mem.Allocator, data: []const u8, level: u8) []u8 {
|
||||
// Use brotli encoder
|
||||
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, // BrotliEncoderMode.generic
|
||||
data.len,
|
||||
data.ptr,
|
||||
&output_size,
|
||||
output.ptr,
|
||||
);
|
||||
|
||||
if (result == 0) {
|
||||
allocator.free(output);
|
||||
// Compression failed - return empty slice to signal error
|
||||
return &[_]u8{};
|
||||
}
|
||||
|
||||
// Shrink to actual size
|
||||
return allocator.realloc(output, output_size) catch output[0..output_size];
|
||||
}
|
||||
|
||||
fn compressGzip(allocator: std.mem.Allocator, data: []const u8, level: u8) []u8 {
|
||||
// Use zlib with gzip wrapper (windowBits = 15 | 16)
|
||||
return compressZlib(allocator, data, level, Zlib.MAX_WBITS | 16);
|
||||
}
|
||||
|
||||
fn compressDeflate(allocator: std.mem.Allocator, data: []const u8, level: u8) []u8 {
|
||||
// Use raw deflate (windowBits = -15)
|
||||
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);
|
||||
|
||||
// Initialize deflate
|
||||
const init_result = deflateInit2_(
|
||||
&stream,
|
||||
@intCast(level),
|
||||
Z_DEFLATED,
|
||||
window_bits,
|
||||
8, // mem level (default)
|
||||
Z_DEFAULT_STRATEGY,
|
||||
Zlib.zlibVersion(),
|
||||
@sizeOf(Zlib.z_stream),
|
||||
);
|
||||
|
||||
if (init_result != .Ok) {
|
||||
return &[_]u8{};
|
||||
}
|
||||
defer _ = deflateEnd(&stream);
|
||||
|
||||
// Allocate output buffer (worst case: input size + 0.1% + 12 bytes)
|
||||
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);
|
||||
|
||||
// Compress
|
||||
const deflate_result = deflate(&stream, .Finish);
|
||||
if (deflate_result != .StreamEnd) {
|
||||
allocator.free(output);
|
||||
return &[_]u8{};
|
||||
}
|
||||
|
||||
const compressed_size = stream.total_out;
|
||||
|
||||
// Shrink to actual size
|
||||
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];
|
||||
}
|
||||
|
||||
/// Check if a MIME type should be compressed
|
||||
/// Compresses text-based formats, skips already-compressed formats
|
||||
pub fn shouldCompressMIME(content_type: ?[]const u8) bool {
|
||||
const mime = content_type orelse return true; // Default: compress
|
||||
|
||||
// Skip already-compressed formats
|
||||
if (bun.strings.hasPrefixComptime(mime, "image/")) return false;
|
||||
if (bun.strings.hasPrefixComptime(mime, "video/")) return false;
|
||||
if (bun.strings.hasPrefixComptime(mime, "audio/")) return false;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/zip")) return false;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/gzip")) return false;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/x-gzip")) return false;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/x-bzip")) return false;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/x-bzip2")) return false;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/x-7z-compressed")) return false;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/x-rar-compressed")) return false;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/octet-stream")) return false;
|
||||
|
||||
// Compress text-based formats
|
||||
if (bun.strings.hasPrefixComptime(mime, "text/")) return true;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/json")) return true;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/javascript")) return true;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/xml")) return true;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/xhtml+xml")) return true;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/rss+xml")) return true;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/atom+xml")) return true;
|
||||
if (bun.strings.hasPrefixComptime(mime, "application/wasm")) return true;
|
||||
if (bun.strings.hasPrefixComptime(mime, "image/svg+xml")) return true;
|
||||
if (bun.strings.hasPrefixComptime(mime, "font/")) return true;
|
||||
|
||||
// Default: don't compress unknown types
|
||||
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,16 @@ pub const Encoding = enum {
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert encoding to Content-Encoding header value
|
||||
pub fn toString(this: Encoding) []const u8 {
|
||||
return switch (this) {
|
||||
.brotli => "br",
|
||||
.gzip => "gzip",
|
||||
.zstd => "zstd",
|
||||
.deflate => "deflate",
|
||||
.identity => "identity",
|
||||
.chunked => unreachable, // chunked is Transfer-Encoding only
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user