diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index 514e8489d09d2d..998da9d243f44d 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -18,6 +18,7 @@ const ReadFileTask = WebCore.Blob.ReadFile.ReadFileTask; const WriteFileTask = WebCore.Blob.WriteFile.WriteFileTask; const napi_async_work = JSC.napi.napi_async_work; const FetchTasklet = Fetch.FetchTasklet; +const S3HttpSimpleTask = @import("../s3.zig").AWSCredentials.S3HttpSimpleTask; const JSValue = JSC.JSValue; const js = JSC.C; const Waker = bun.Async.Waker; @@ -407,6 +408,7 @@ const ServerAllConnectionsClosedTask = @import("./api/server.zig").ServerAllConn // Task.get(ReadFileTask) -> ?ReadFileTask pub const Task = TaggedPointerUnion(.{ FetchTasklet, + S3HttpSimpleTask, AsyncGlobWalkTask, AsyncTransformTask, ReadFileTask, @@ -991,6 +993,10 @@ pub const EventLoop = struct { var fetch_task: *Fetch.FetchTasklet = task.get(Fetch.FetchTasklet).?; fetch_task.onProgressUpdate(); }, + .S3HttpSimpleTask => { + var s3_task: *S3HttpSimpleTask = task.get(S3HttpSimpleTask).?; + s3_task.onResponse(); + }, @field(Task.Tag, @typeName(AsyncGlobWalkTask)) => { var globWalkTask: *AsyncGlobWalkTask = task.get(AsyncGlobWalkTask).?; globWalkTask.*.runFromJS(); diff --git a/src/bun.js/webcore/blob.zig b/src/bun.js/webcore/blob.zig index 9c1467bf5d8c0d..59781eba20a23b 100644 --- a/src/bun.js/webcore/blob.zig +++ b/src/bun.js/webcore/blob.zig @@ -19,7 +19,6 @@ const default_allocator = bun.default_allocator; const FeatureFlags = bun.FeatureFlags; const ArrayBuffer = @import("../base.zig").ArrayBuffer; const Properties = @import("../base.zig").Properties; - const getAllocator = @import("../base.zig").getAllocator; const Environment = @import("../../env.zig"); @@ -44,6 +43,7 @@ const Request = JSC.WebCore.Request; const libuv = bun.windows.libuv; +const AWS = @import("../../s3.zig").AWSCredentials; const PathOrBlob = union(enum) { path: JSC.Node.PathOrFileDescriptor, blob: Blob, @@ -147,6 +147,14 @@ pub const Blob = struct { pub fn doReadFile(this: *Blob, comptime Function: anytype, global: *JSGlobalObject) JSValue { bloblog("doReadFile", .{}); + if (this.isS3()) { + const WrappedFn = struct { + pub fn wrapped(b: *Blob, g: *JSGlobalObject, by: []u8) JSC.JSValue { + return JSC.toJSHostValue(g, Function(b, g, by, .clone)); + } + }; + return S3BlobDownloadTask.init(global, this, WrappedFn.wrapped); + } const Handler = NewReadFileHandler(Function); @@ -3423,12 +3431,149 @@ pub const Blob = struct { return JSValue.jsBoolean(bun.isRegularFile(store.data.file.mode) or bun.C.S.ISFIFO(store.data.file.mode)); } + fn isS3(this: *Blob) bool { + if (this.store) |store| { + if (store.data == .file) { + if (store.data.file.pathlike == .path) { + const slice = store.data.file.pathlike.path.slice(); + return strings.startsWith(slice, "s3://"); + } + } + } + return false; + } + + const S3BlobDownloadTask = struct { + blob: Blob, + globalThis: *JSC.JSGlobalObject, + promise: JSC.JSPromise.Strong, + poll_ref: bun.Async.KeepAlive = .{}, + + handler: S3ReadHandler, + usingnamespace bun.New(S3BlobDownloadTask); + pub const S3ReadHandler = *const fn (this: *Blob, globalthis: *JSGlobalObject, raw_bytes: []u8) JSValue; + + pub fn callHandler(this: *S3BlobDownloadTask, raw_bytes: []u8) JSValue { + return this.handler(&this.blob, this.globalThis, raw_bytes); + } + pub fn onS3DownloadResolved(result: AWS.S3DownloadResult, this: *S3BlobDownloadTask) void { + defer this.deinit(); + switch (result) { + .not_found => { + const js_err = this.globalThis.createErrorInstance("File not found", .{}); + js_err.put(this.globalThis, ZigString.static("code"), ZigString.init("FileNotFound").toJS(this.globalThis)); + this.promise.reject(this.globalThis, js_err); + }, + .success => |response| { + const bytes = response.body.list.items; + if (this.blob.size == Blob.max_size) { + this.blob.size = @truncate(bytes.len); + } + JSC.AnyPromise.wrap(.{ .normal = this.promise.get() }, this.globalThis, S3BlobDownloadTask.callHandler, .{ this, bytes }); + }, + .failure => |err| { + const js_err = this.globalThis.createErrorInstance("{s}", .{err.message}); + js_err.put(this.globalThis, ZigString.static("code"), ZigString.init(err.code).toJS(this.globalThis)); + this.promise.rejectOnNextTick(this.globalThis, js_err); + }, + } + } + + pub fn init(globalThis: *JSC.JSGlobalObject, blob: *Blob, handler: S3BlobDownloadTask.S3ReadHandler) JSValue { + blob.store.?.ref(); + + const this = S3BlobDownloadTask.new(.{ + .globalThis = globalThis, + .blob = blob.*, + .promise = JSC.JSPromise.Strong.init(globalThis), + .handler = handler, + }); + const promise = this.promise.value(); + const env = this.globalThis.bunVM().bundler.env; + const credentials = env.getAWSCredentials(); + const url = bun.URL.parse(this.blob.store.?.data.file.pathlike.path.slice()); + this.poll_ref.ref(globalThis.bunVM()); + + if (blob.offset > 0) { + const len: ?usize = if (blob.size != Blob.max_size) @intCast(blob.size) else null; + const offset: usize = @intCast(blob.offset); + credentials.s3DownloadSlice(url.hostname, url.path, offset, len, @ptrCast(&S3BlobDownloadTask.onS3DownloadResolved), this, if (env.getHttpProxy(url)) |proxy| proxy.href else null); + } else { + credentials.s3Download(url.hostname, url.path, @ptrCast(&S3BlobDownloadTask.onS3DownloadResolved), this, if (env.getHttpProxy(url)) |proxy| proxy.href else null); + } + return promise; + } + + pub fn deinit(this: *S3BlobDownloadTask) void { + this.blob.store.?.deref(); + this.poll_ref.unrefOnNextTick(this.globalThis.bunVM()); + this.promise.deinit(); + this.destroy(); + } + }; + + const S3BlobStatTask = struct { + blob: *Blob, + globalThis: *JSC.JSGlobalObject, + promise: JSC.JSPromise.Strong, + strong_ref: JSC.Strong, + poll_ref: bun.Async.KeepAlive = .{}, + usingnamespace bun.New(S3BlobStatTask); + + pub fn onS3StatResolved(result: AWS.S3StatResult, this: *S3BlobStatTask) void { + defer this.deinit(); + switch (result) { + .not_found => { + this.promise.resolve(this.globalThis, .false); + }, + .success => |stat| { + if (this.blob.size == Blob.max_size) { + this.blob.size = @truncate(stat.size); + } + this.promise.resolve(this.globalThis, .true); + }, + .failure => |err| { + const js_err = this.globalThis.createErrorInstance("{s}", .{err.message}); + js_err.put(this.globalThis, ZigString.static("code"), ZigString.init(err.code).toJS(this.globalThis)); + this.promise.rejectOnNextTick(this.globalThis, js_err); + }, + } + } + + pub fn init(globalThis: *JSC.JSGlobalObject, blob: *Blob, js_blob: JSValue) JSValue { + const this = S3BlobStatTask.new(.{ + .globalThis = globalThis, + .blob = blob, + .promise = JSC.JSPromise.Strong.init(globalThis), + .strong_ref = JSC.Strong.create(js_blob, globalThis), + }); + const promise = this.promise.value(); + const env = this.globalThis.bunVM().bundler.env; + const credentials = env.getAWSCredentials(); + const url = bun.URL.parse(this.blob.store.?.data.file.pathlike.path.slice()); + this.poll_ref.ref(globalThis.bunVM()); + credentials.s3Stat(url.hostname, url.path, @ptrCast(&S3BlobStatTask.onS3StatResolved), this, if (env.getHttpProxy(url)) |proxy| proxy.href else null); + return promise; + } + + pub fn deinit(this: *S3BlobStatTask) void { + this.poll_ref.unrefOnNextTick(this.globalThis.bunVM()); + this.strong_ref.deinit(); + this.promise.deinit(); + this.destroy(); + } + }; + // This mostly means 'can it be read?' pub fn getExists( this: *Blob, globalThis: *JSC.JSGlobalObject, _: *JSC.CallFrame, + this_value: JSC.JSValue, ) bun.JSError!JSValue { + if (this.isS3()) { + return S3BlobStatTask.init(globalThis, this, this_value); + } return JSC.JSPromise.resolvedPromiseValue(globalThis, this.getExistsSync()); } @@ -3783,7 +3928,7 @@ pub const Blob = struct { if (this.store) |store| { if (store.data == .file) { // last_modified can be already set during read. - if (store.data.file.last_modified == JSC.init_timestamp) { + if (store.data.file.last_modified == JSC.init_timestamp and !this.isS3()) { resolveFileStat(store); } return JSValue.jsNumber(store.data.file.last_modified); diff --git a/src/bun.js/webcore/response.classes.ts b/src/bun.js/webcore/response.classes.ts index 157f0abc387b8c..26c89991d7c243 100644 --- a/src/bun.js/webcore/response.classes.ts +++ b/src/bun.js/webcore/response.classes.ts @@ -140,7 +140,7 @@ export default [ slice: { fn: "getSlice", length: 2 }, stream: { fn: "getStream", length: 1 }, formData: { fn: "getFormData" }, - exists: { fn: "getExists", length: 0 }, + exists: { fn: "getExists", length: 0, passThis: true }, // Non-standard, but consistent! bytes: { fn: "getBytes" }, diff --git a/src/bun.js/webcore/response.zig b/src/bun.js/webcore/response.zig index fcb70c3cf0027a..b809bb4fa9a237 100644 --- a/src/bun.js/webcore/response.zig +++ b/src/bun.js/webcore/response.zig @@ -2274,6 +2274,7 @@ pub const Fetch = struct { var signal: ?*JSC.WebCore.AbortSignal = null; // Custom Hostname var hostname: ?[]u8 = null; + var range: ?[]u8 = null; var unix_socket_path: ZigString.Slice = ZigString.Slice.empty; var url_proxy_buffer: []const u8 = ""; @@ -2312,6 +2313,10 @@ pub const Fetch = struct { bun.default_allocator.free(hn); hostname = null; } + if (range) |range_| { + bun.default_allocator.free(range_); + range = null; + } if (ssl_config) |conf| { ssl_config = null; @@ -2929,6 +2934,15 @@ pub const Fetch = struct { } hostname = _hostname.toOwnedSliceZ(allocator) catch bun.outOfMemory(); } + if (url.isS3()) { + if (headers_.fastGet(JSC.FetchHeaders.HTTPHeaderName.Range)) |_range| { + if (range) |range_| { + range = null; + allocator.free(range_); + } + range = _range.toOwnedSliceZ(allocator) catch bun.outOfMemory(); + } + } break :extract_headers Headers.from(headers_, allocator, .{ .body = body.getAnyBlob() }) catch bun.outOfMemory(); } @@ -3228,7 +3242,7 @@ pub const Fetch = struct { } } // TODO: should we generate the content hash? presigned never uses content-hash, maybe only if a extra option is passed to avoid the cost - var result = credentials.s3Request(url.hostname, url.path, method, null) catch |sign_err| { + var result = credentials.signRequest(url.hostname, url.path, method, null) catch |sign_err| { switch (sign_err) { error.MissingCredentials => { const err = JSC.toTypeError(.ERR_INVALID_ARG_VALUE, "missing s3 credentials", .{}, ctx); @@ -3236,7 +3250,7 @@ pub const Fetch = struct { return JSPromise.rejectedPromiseValue(globalThis, err); }, error.InvalidMethod => { - const err = JSC.toTypeError(.ERR_INVALID_ARG_VALUE, "method must be GET, PUT, DELETE when using s3 protocol", .{}, ctx); + const err = JSC.toTypeError(.ERR_INVALID_ARG_VALUE, "method must be GET, PUT, DELETE or HEAD when using s3 protocol", .{}, ctx); is_error = true; return JSPromise.rejectedPromiseValue(globalThis, err); }, @@ -3274,7 +3288,18 @@ pub const Fetch = struct { if (headers) |*headers_| { headers_.deinit(); } - headers = Headers.fromPicoHttpHeaders(&result.headers, allocator) catch bun.outOfMemory(); + if (range) |range_| { + var headersWithRange: [5]picohttp.Header = .{ + result.headers[0], + result.headers[1], + result.headers[2], + result.headers[3], + .{ .name = "range", .value = range_ }, + }; + headers = Headers.fromPicoHttpHeaders(&headersWithRange, allocator) catch bun.outOfMemory(); + } else { + headers = Headers.fromPicoHttpHeaders(&result.headers, allocator) catch bun.outOfMemory(); + } } // Only create this after we have validated all the input. diff --git a/src/s3.zig b/src/s3.zig index 72f575373fa683..5de1cac34f4cc3 100644 --- a/src/s3.zig +++ b/src/s3.zig @@ -86,7 +86,7 @@ pub const AWSCredentials = struct { } }; - pub fn s3Request(this: *const @This(), bucket: []const u8, path: []const u8, method: bun.http.Method, content_hash: ?[]const u8) !SignResult { + pub fn signRequest(this: *const @This(), bucket: []const u8, path: []const u8, method: bun.http.Method, content_hash: ?[]const u8) !SignResult { if (this.accessKeyId.len == 0 or this.secretAccessKey.len == 0) return error.MissingCredentials; const method_name = switch (method) { @@ -189,4 +189,296 @@ pub const AWSCredentials = struct { }, }; } + + pub const S3StatResult = union(enum) { + success: struct { + size: usize = 0, + /// etag is not owned and need to be copied if used after this callback + etag: []const u8 = "", + }, + not_found: void, + + /// failure error is not owned and need to be copied if used after this callback + failure: struct { + code: []const u8, + message: []const u8, + }, + }; + pub const S3DownloadResult = union(enum) { + success: struct { + /// etag is not owned and need to be copied if used after this callback + etag: []const u8 = "", + /// body is owned and dont need to be copied, but dont forget to free it + body: bun.MutableString, + }, + not_found: void, + /// failure error is not owned and need to be copied if used after this callback + failure: struct { + code: []const u8, + message: []const u8, + }, + }; + pub const S3UploadResult = union(enum) { + success: void, + /// failure error is not owned and need to be copied if used after this callback + failure: struct { + code: []const u8, + message: []const u8, + }, + }; + pub const S3DeleteResult = union(enum) { + success: void, + not_found: void, + + /// failure error is not owned and need to be copied if used after this callback + failure: struct { + code: []const u8, + message: []const u8, + }, + }; + pub const S3HttpSimpleTask = struct { + http: bun.http.AsyncHTTP, + vm: *JSC.VirtualMachine, + sign_result: SignResult, + headers: JSC.WebCore.Headers, + callback_context: *anyopaque, + callback: Callback, + response_buffer: bun.MutableString = .{ + .allocator = bun.default_allocator, + .list = .{ + .items = &.{}, + .capacity = 0, + }, + }, + result: bun.http.HTTPClientResult = .{}, + concurrent_task: JSC.ConcurrentTask = .{}, + range: ?[]const u8, + + usingnamespace bun.New(@This()); + pub const Callback = union(enum) { + stat: *const fn (S3StatResult, *anyopaque) void, + download: *const fn (S3DownloadResult, *anyopaque) void, + upload: *const fn (S3UploadResult, *anyopaque) void, + delete: *const fn (S3DeleteResult, *anyopaque) void, + + pub fn fail(this: @This(), code: []const u8, message: []const u8, context: *anyopaque) void { + switch (this) { + inline .upload, .download, .stat, .delete => |callback| callback(.{ + .failure = .{ + .code = code, + .message = message, + }, + }, context), + } + } + }; + pub fn deinit(this: *@This()) void { + if (this.result.certificate_info) |*certificate| { + certificate.deinit(bun.default_allocator); + } + + this.response_buffer.deinit(); + this.headers.deinit(); + this.sign_result.deinit(); + this.http.clearData(); + if (this.range) |range| { + bun.default_allocator.free(range); + } + if (this.result.metadata) |*metadata| { + metadata.deinit(bun.default_allocator); + } + this.destroy(); + } + + fn fail(this: @This()) void { + var code: []const u8 = "UnknownError"; + var message: []const u8 = "an unexpected error has occurred"; + if (this.result.body) |body| { + const bytes = body.list.items; + if (bytes.len > 0) { + message = bytes[0..]; + if (strings.indexOf(bytes, "")) |start| { + if (strings.indexOf(bytes, "")) |end| { + code = bytes[start + "".len .. end]; + } + } + if (strings.indexOf(bytes, "")) |start| { + if (strings.indexOf(bytes, "")) |end| { + message = bytes[start + "".len .. end]; + } + } + } + } + this.callback.fail(code, message, this.callback_context); + } + + pub fn onResponse(this: *@This()) void { + defer this.deinit(); + bun.assert(this.result.metadata != null); + const response = this.result.metadata.?.response; + switch (this.callback) { + .stat => |callback| { + switch (response.status_code) { + 404 => { + callback(.{ .not_found = {} }, this.callback_context); + }, + 200 => { + callback(.{ + .success = .{ + .etag = response.headers.get("etag") orelse "", + .size = if (response.headers.get("content-length")) |content_len| (std.fmt.parseInt(usize, content_len, 10) catch 0) else 0, + }, + }, this.callback_context); + }, + else => { + this.fail(); + }, + } + }, + .delete => |callback| { + switch (response.status_code) { + 404 => { + callback(.{ .not_found = {} }, this.callback_context); + }, + 200 => { + callback(.{ .success = {} }, this.callback_context); + }, + else => { + this.fail(); + }, + } + }, + .upload => |callback| { + switch (response.status_code) { + 200 => { + callback(.{ .success = {} }, this.callback_context); + }, + else => { + this.fail(); + }, + } + }, + .download => |callback| { + switch (response.status_code) { + 404 => { + callback(.{ .not_found = {} }, this.callback_context); + }, + 200, 206 => { + const body = this.response_buffer; + this.response_buffer = .{ + .allocator = bun.default_allocator, + .list = .{ + .items = &.{}, + .capacity = 0, + }, + }; + callback(.{ + .success = .{ + .etag = response.headers.get("etag") orelse "", + .body = body, + }, + }, this.callback_context); + }, + else => { + //error + this.fail(); + }, + } + }, + } + } + + pub fn http_callback(this: *@This(), async_http: *bun.http.AsyncHTTP, result: bun.http.HTTPClientResult) void { + const is_done = !result.has_more; + this.result = result; + this.http = async_http.*; + this.http.response_buffer = async_http.response_buffer; + if (is_done) { + this.vm.eventLoop().enqueueTaskConcurrent(this.concurrent_task.from(this, .manual_deinit)); + } + } + }; + + pub fn executeSimpleS3Request(this: *const @This(), bucket: []const u8, path: []const u8, method: bun.http.Method, callback: S3HttpSimpleTask.Callback, callback_context: *anyopaque, proxy_url: ?[]const u8, body: []const u8, range: ?[]const u8) void { + var result = this.signRequest(bucket, path, method, null) catch |sign_err| { + if (range) |range_| bun.default_allocator.free(range_); + + return switch (sign_err) { + error.MissingCredentials => callback.fail("MissingCredentials", "missing s3 credentials", callback_context), + error.InvalidMethod => callback.fail("MissingCredentials", "method must be GET, PUT, DELETE or HEAD when using s3 protocol", callback_context), + error.InvalidPath => callback.fail("InvalidPath", "invalid s3 bucket, key combination", callback_context), + else => callback.fail("SignError", "failed to retrieve s3 content check your credentials", callback_context), + }; + }; + + const headers = brk: { + if (range) |range_| { + var headersWithRange: [5]picohttp.Header = .{ + result.headers[0], + result.headers[1], + result.headers[2], + result.headers[3], + .{ .name = "range", .value = range_ }, + }; + break :brk JSC.WebCore.Headers.fromPicoHttpHeaders(&headersWithRange, bun.default_allocator) catch bun.outOfMemory(); + } else { + break :brk JSC.WebCore.Headers.fromPicoHttpHeaders(&result.headers, bun.default_allocator) catch bun.outOfMemory(); + } + }; + const task = S3HttpSimpleTask.new(.{ + .http = undefined, + .sign_result = result, + .callback_context = callback_context, + .callback = callback, + .range = range, + .headers = headers, + .vm = JSC.VirtualMachine.get(), + }); + + const url = bun.URL.parse(result.url); + + task.http = bun.http.AsyncHTTP.init( + bun.default_allocator, + method, + url, + task.headers.entries, + task.headers.buf.items, + &task.response_buffer, + body, + bun.http.HTTPClientResult.Callback.New( + *S3HttpSimpleTask, + S3HttpSimpleTask.http_callback, + ).init(task), + .follow, + .{ + .http_proxy = if (proxy_url) |proxy| bun.URL.parse(proxy) else null, + }, + ); + // queue http request + bun.http.HTTPThread.init(&.{}); + var batch = bun.ThreadPool.Batch{}; + task.http.schedule(bun.default_allocator, &batch); + bun.http.http_thread.schedule(batch); + } + + pub fn s3Stat(this: *const @This(), bucket: []const u8, path: []const u8, callback: *const fn (S3StatResult, *anyopaque) void, callback_context: *anyopaque, proxy_url: ?[]const u8) void { + this.executeSimpleS3Request(bucket, path, .HEAD, .{ .stat = callback }, callback_context, proxy_url, "", null); + } + + pub fn s3Download(this: *const @This(), bucket: []const u8, path: []const u8, callback: *const fn (S3DownloadResult, *anyopaque) void, callback_context: *anyopaque, proxy_url: ?[]const u8) void { + this.executeSimpleS3Request(bucket, path, .GET, .{ .download = callback }, callback_context, proxy_url, "", null); + } + + pub fn s3DownloadSlice(this: *const @This(), bucket: []const u8, path: []const u8, offset: usize, len: ?usize, callback: *const fn (S3DownloadResult, *anyopaque) void, callback_context: *anyopaque, proxy_url: ?[]const u8) void { + const range = if (len != null) std.fmt.allocPrint(bun.default_allocator, "bytes={}-{}", .{ offset, offset + len.? }) catch bun.outOfMemory() else std.fmt.allocPrint(bun.default_allocator, "bytes={}-", .{offset}) catch bun.outOfMemory(); + this.executeSimpleS3Request(bucket, path, .GET, .{ .download = callback }, callback_context, proxy_url, "", range); + } + + pub fn s3Delete(this: *const @This(), bucket: []const u8, path: []const u8, callback: *const fn (S3DeleteResult, *anyopaque) void, callback_context: *anyopaque, proxy_url: ?[]const u8) void { + this.executeSimpleS3Request(bucket, path, .DELETE, .{ .delete = callback }, callback_context, proxy_url, "", null); + } + + pub fn s3Upload(this: *const @This(), bucket: []const u8, path: []const u8, content: []const u8, callback: *const fn (S3UploadResult, *anyopaque) void, callback_context: *anyopaque, proxy_url: ?[]const u8) void { + this.executeSimpleS3Request(bucket, path, .POST, .{ .upload = callback }, callback_context, proxy_url, content, null); + } };