aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Jarred Sumner <709451+Jarred-Sumner@users.noreply.github.com> 2022-12-04 00:55:05 -0800
committerGravatar Jarred Sumner <709451+Jarred-Sumner@users.noreply.github.com> 2022-12-04 00:55:05 -0800
commit0617896d7045e129abac8d7fd22df0e6626d92c8 (patch)
tree144c597fbff7c071713acea7a1d2e7e02faffbb4
parent1c3cb22d1f99d112200ae689896d46543631fad2 (diff)
downloadbun-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.md16
-rw-r--r--src/bun.js/api/server.zig120
-rw-r--r--src/bun.js/webcore/response.zig5
-rw-r--r--src/bun.js/webcore/streams.zig1
-rw-r--r--test/bun.js/serve.test.ts160
5 files changed, 286 insertions, 16 deletions
diff --git a/README.md b/README.md
index 806003635..2fec9861b 100644
--- a/README.md
+++ b/README.md
@@ -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;
+ }
+ }
+ });
+ }
+});