diff options
author | 2022-12-04 00:55:05 -0800 | |
---|---|---|
committer | 2022-12-04 00:55:05 -0800 | |
commit | 0617896d7045e129abac8d7fd22df0e6626d92c8 (patch) | |
tree | 144c597fbff7c071713acea7a1d2e7e02faffbb4 | |
parent | 1c3cb22d1f99d112200ae689896d46543631fad2 (diff) | |
download | bun-0617896d7045e129abac8d7fd22df0e6626d92c8.tar.gz bun-0617896d7045e129abac8d7fd22df0e6626d92c8.tar.zst bun-0617896d7045e129abac8d7fd22df0e6626d92c8.zip |
[Bun.serve] Implement `Content-Range` support with `Bun.file()`
Diffstat (limited to '')
-rw-r--r-- | README.md | 16 | ||||
-rw-r--r-- | src/bun.js/api/server.zig | 120 | ||||
-rw-r--r-- | src/bun.js/webcore/response.zig | 5 | ||||
-rw-r--r-- | src/bun.js/webcore/streams.zig | 1 | ||||
-rw-r--r-- | test/bun.js/serve.test.ts | 160 |
5 files changed, 286 insertions, 16 deletions
@@ -2118,6 +2118,22 @@ Bun.serve({ }); ``` +### Sending files with Bun.serve() + +`Bun.serve()` lets you send files fast. + +To send a file, return a `Response` object with a `Bun.file(pathOrFd)` object as the body. + +```ts +Bun.serve({ + fetch(req) { + return new Response(Bun.file("./hello.txt")); + }, +}); +``` + +Under the hood, when TLS is not enabled, Bun automatically uses the sendfile(2) system call. This enables zero-copy file transfers, which is faster than reading the file into memory and sending it. + ### WebSockets with Bun.serve() `Bun.serve()` has builtin support for server-side websockets (as of Bun v0.2.1). diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index e2892f88f..9f3256c97 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -646,6 +646,7 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp has_sendfile_ctx: bool = false, has_called_error_handler: bool = false, needs_content_length: bool = false, + needs_content_range: bool = false, sendfile: SendfileContext = undefined, request_js_object: JSC.C.JSObjectRef = null, request_body_buf: std.ArrayListUnmanaged(u8) = .{}, @@ -883,6 +884,28 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp return false; } + // TODO: should we cork? + pub fn onWritableCompleteResponseBufferAndMetadata(this: *RequestContext, write_offset: c_ulong, resp: *App.Response) callconv(.C) bool { + std.debug.assert(this.resp == resp); + + if (this.aborted) { + this.finalizeForAbort(); + return false; + } + + if (!this.has_written_status) { + this.renderMetadata(); + } + + if (this.method == .HEAD) { + resp.end("", this.shouldCloseConnection()); + this.finalize(); + return false; + } + + return this.sendWritableBytesForCompleteResponseBuffer(this.response_buf_owned.items, write_offset, resp); + } + pub fn onWritableCompleteResponseBuffer(this: *RequestContext, write_offset: c_ulong, resp: *App.Response) callconv(.C) bool { std.debug.assert(this.resp == resp); if (this.aborted) { @@ -1119,7 +1142,7 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp const errcode = linux.getErrno(val); - this.sendfile.remain -= @intCast(Blob.SizeType, this.sendfile.offset - start); + this.sendfile.remain -|= @intCast(Blob.SizeType, this.sendfile.offset -| start); if (errcode != .SUCCESS or this.aborted or this.sendfile.remain == 0 or val == 0) { if (errcode != .AGAIN and errcode != .SUCCESS and errcode != .PIPE) { @@ -1132,7 +1155,6 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp } else { var sbytes: std.os.off_t = adjusted_count; const signed_offset = @bitCast(i64, @as(u64, this.sendfile.offset)); - const errcode = std.c.getErrno(std.c.sendfile( this.sendfile.fd, this.sendfile.socket_fd, @@ -1143,8 +1165,8 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp 0, )); const wrote = @intCast(Blob.SizeType, sbytes); - this.sendfile.offset += wrote; - this.sendfile.remain -= wrote; + this.sendfile.offset +|= wrote; + this.sendfile.remain -|= wrote; if (errcode != .AGAIN or this.aborted or this.sendfile.remain == 0 or sbytes == 0) { if (errcode != .AGAIN and errcode != .SUCCESS and errcode != .PIPE) { Output.prettyErrorln("Error: {s}", .{@tagName(errcode)}); @@ -1280,19 +1302,39 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp } } - this.blob.Blob.size = @intCast(Blob.SizeType, stat.size); + const original_size = this.blob.Blob.size; + const stat_size = @intCast(Blob.SizeType, stat.size); + this.blob.Blob.size = if (std.os.S.ISREG(stat.mode)) + stat_size + else + @minimum(original_size, stat_size); + this.needs_content_length = true; this.sendfile = .{ .fd = fd, - .remain = this.blob.Blob.size, + .remain = this.blob.Blob.offset + original_size, + .offset = this.blob.Blob.offset, .auto_close = auto_close, .socket_fd = if (!this.aborted) this.resp.getNativeHandle() else -999, }; + // if we are sending only part of a file, include the content-range header + // only include content-range automatically when using a file path instead of an fd + // this is to better support manually controlling the behavior + if (std.os.S.ISREG(stat.mode) and auto_close) { + this.needs_content_range = (this.sendfile.remain -| this.sendfile.offset) != stat_size; + } + + // we know the bounds when we are sending a regular file + if (std.os.S.ISREG(stat.mode)) { + this.sendfile.offset = @minimum(this.sendfile.offset, stat_size); + this.sendfile.remain = @minimum(@maximum(this.sendfile.remain, this.sendfile.offset), stat_size) -| this.sendfile.offset; + } + this.resp.runCorkedWithType(*RequestContext, renderMetadataAndNewline, this); - if (this.blob.Blob.size == 0) { + if (this.sendfile.remain == 0 or !this.method.hasBody()) { this.cleanupAndFinalizeAfterSendfile(); return; } @@ -1339,9 +1381,28 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp this.blob.Blob.resolveSize(); this.doRenderBlob(); } else { - this.blob.Blob.size = @truncate(Blob.SizeType, result.result.buf.len); + const stat_size = @intCast(Blob.SizeType, result.result.total_size); + const original_size = this.blob.Blob.size; + + this.blob.Blob.size = if (original_size == 0 or original_size == Blob.max_size) + stat_size + else + @minimum(original_size, stat_size); + + if (!this.has_written_status) + this.needs_content_range = true; + + // this is used by content-range + this.sendfile = .{ + .fd = @truncate(i32, bun.invalid_fd), + .remain = @truncate(Blob.SizeType, result.result.buf.len), + .offset = this.blob.Blob.offset, + .auto_close = false, + .socket_fd = -999, + }; + this.response_buf_owned = .{ .items = result.result.buf, .capacity = result.result.buf.len }; - this.resp.onWritable(*RequestContext, onWritableCompleteResponseBuffer, this); + this.resp.onWritable(*RequestContext, onWritableCompleteResponseBufferAndMetadata, this); } } @@ -2078,13 +2139,18 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp pub fn renderMetadata(this: *RequestContext) void { var response: *JSC.WebCore.Response = this.response_ptr.?; var status = response.statusCode(); - const size = this.blob.size(); + var needs_content_range = this.needs_content_range; + + const size = if (needs_content_range) + this.sendfile.remain + else + this.blob.size(); + status = if (status == 200 and size == 0 and !this.blob.isDetached()) 204 else status; - this.writeStatus(status); var needs_content_type = true; const content_type: MimeType = brk: { if (response.body.init.headers) |headers_| { @@ -2105,12 +2171,23 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp }; var has_content_disposition = false; - if (response.body.init.headers) |headers_| { - this.writeHeaders(headers_); has_content_disposition = headers_.fastHas(.ContentDisposition); + needs_content_range = needs_content_range and headers_.fastHas(.ContentRange); + if (needs_content_range) { + status = 206; + } + + this.writeStatus(status); + this.writeHeaders(headers_); + response.body.init.headers = null; headers_.deref(); + } else if (needs_content_range) { + status = 206; + this.writeStatus(status); + } else { + this.writeStatus(status); } if (needs_content_type and @@ -2146,6 +2223,23 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp this.resp.writeHeaderInt("content-length", size); this.needs_content_length = false; } + + if (needs_content_range) { + var content_range_buf: [1024]u8 = undefined; + + this.resp.writeHeader( + "content-range", + std.fmt.bufPrint( + &content_range_buf, + // we omit the full size of the Blob because it could + // change between requests and this potentially leaks + // PII undesirably + "bytes {d}-{d}/*", + .{ this.sendfile.offset, this.sendfile.offset + this.sendfile.remain }, + ) catch "bytes */*", + ); + this.needs_content_range = false; + } } pub fn renderBytes(this: *RequestContext) void { diff --git a/src/bun.js/webcore/response.zig b/src/bun.js/webcore/response.zig index d6332b993..b31bd3075 100644 --- a/src/bun.js/webcore/response.zig +++ b/src/bun.js/webcore/response.zig @@ -2010,6 +2010,7 @@ pub const Blob = struct { pub const Read = struct { buf: []u8, is_temporary: bool = false, + total_size: SizeType = 0, }; pub const ResultType = SystemError.Maybe(Read); @@ -2105,7 +2106,7 @@ pub const Blob = struct { return; } - cb(cb_ctx, .{ .result = .{ .buf = buf, .is_temporary = true } }); + cb(cb_ctx, .{ .result = .{ .buf = buf, .total_size = this.size, .is_temporary = true } }); } pub fn run(this: *ReadFile, task: *ReadFileTask) void { this.runAsync(task); @@ -2154,6 +2155,8 @@ pub const Blob = struct { const file = &this.file_store; const needs_close = fd != null_fd and file.pathlike == .path and fd > 2; + this.size = @maximum(this.read_len, this.size); + if (needs_close) { this.doClose(); } diff --git a/src/bun.js/webcore/streams.zig b/src/bun.js/webcore/streams.zig index 801fb4741..e9c075074 100644 --- a/src/bun.js/webcore/streams.zig +++ b/src/bun.js/webcore/streams.zig @@ -4445,7 +4445,6 @@ pub const FileReader = struct { return .{ .owned_and_done = this.drainInternalBuffer() }; } - return this.readable().read(buffer, view, this.globalThis()); } diff --git a/test/bun.js/serve.test.ts b/test/bun.js/serve.test.ts index 8dc3e3d45..e8825d94f 100644 --- a/test/bun.js/serve.test.ts +++ b/test/bun.js/serve.test.ts @@ -1,6 +1,6 @@ import { file, gc, serve } from "bun"; import { afterEach, describe, it, expect } from "bun:test"; -import { readFileSync } from "fs"; +import { readFile, readFileSync, writeFileSync } from "fs"; import { resolve } from "path"; afterEach(() => Bun.gc(true)); @@ -711,3 +711,161 @@ it("should support multiple Set-Cookie headers", async () => { "baz=qux", ]); }); + +describe("should support Content-Range with Bun.file()", () => { + var server; + var full; + + const fixture = resolve(import.meta.dir + "/fetch.js.txt") + ".big"; + + // this must be a big file so we can test potentially multiple chunks + // more than 65 KB + function getFull() { + if (full) return full; + console.log("here"); + const fixture = resolve(import.meta.dir + "/fetch.js.txt"); + const chunk = readFileSync(fixture); + var whole = new Uint8Array(chunk.byteLength * 128); + for (var i = 0; i < 128; i++) { + whole.set(chunk, i * chunk.byteLength); + } + writeFileSync(fixture + ".big", whole); + return (full = whole); + } + + function getServer() { + server ||= serve({ + port: port++, + fetch(req) { + const { searchParams } = new URL(req.url); + const start = Number(searchParams.get("start")); + const end = Number(searchParams.get("end")); + return new Response(Bun.file(fixture).slice(start, end)); + }, + }); + } + + describe("good range", () => { + getFull(); + + const good = [ + [0, 1], + [1, 2], + [0, 10], + [10, 20], + [0, Infinity], + [NaN, Infinity], + [full.byteLength - 10, full.byteLength], + [full.byteLength - 10, full.byteLength - 1], + [full.byteLength - 1, full.byteLength], + [0, full.byteLength], + ] as const; + + for (let [start, end] of good) { + const last = start === good.at(-1)![0] && end === good.at(-1)![1]; + + it(`range: ${start} - ${end}`, async () => { + try { + getFull(); + getServer(); + + await 1; + const response = await fetch( + `http://${server.hostname}:${server.port}/?start=${start}&end=${end}`, + {}, + { verbose: true }, + ); + expect(await response.arrayBuffer()).toEqual( + full.buffer.slice(start, end), + ); + expect(response.status).toBe( + end - start === full.byteLength ? 200 : 206, + ); + } catch (e) { + throw e; + } finally { + if (last) { + server.stop(); + server = null; + } + } + }); + } + }); + + const emptyRanges = [ + [0, 0], + [1, 1], + [10, 10], + [-Infinity, -Infinity], + [Infinity, Infinity], + [NaN, NaN], + [(full.byteLength / 2) | 0, (full.byteLength / 2) | 0], + [full.byteLength, full.byteLength], + [full.byteLength - 1, full.byteLength - 1], + ]; + + for (let [start, end] of emptyRanges) { + it(`empty range: ${start} - ${end}`, async () => { + const last = + start === emptyRanges.at(-1)[0] && end === emptyRanges.at(-1)[1]; + + try { + getFull(); + getServer(); + + const response = await fetch( + `http://${server.hostname}:${server.port}/?start=${start}&end=${end}`, + ); + const out = await response.arrayBuffer(); + expect(out).toEqual(new ArrayBuffer(0)); + expect(response.status).toBe(206); + } catch (e) { + throw e; + } finally { + if (last) { + server.stop(); + server = null; + } + } + }); + } + + getFull(); + + const badRanges = [ + [10, NaN], + [10, -Infinity], + [-(full.byteLength / 2) | 0, Infinity], + [-(full.byteLength / 2) | 0, -Infinity], + [full.byteLength + 100, full.byteLength], + [full.byteLength + 100, full.byteLength + 100], + [full.byteLength + 100, full.byteLength + 1], + [full.byteLength + 100, -full.byteLength], + ]; + + for (let [start, end] of badRanges) { + it(`bad range: ${start} - ${end}`, async () => { + const last = start === badRanges.at(-1)[0] && end === badRanges.at(-1)[1]; + + try { + getFull(); + getServer(); + + const response = await fetch( + `http://${server.hostname}:${server.port}/?start=${start}&end=${end}`, + ); + const out = await response.arrayBuffer(); + expect(out).toEqual(new ArrayBuffer(0)); + expect(response.status).toBe(206); + } catch (e) { + throw e; + } finally { + if (last) { + server.stop(); + server = null; + } + } + }); + } +}); |