diff --git a/cmake/sources/ZigGeneratedClassesSources.txt b/cmake/sources/ZigGeneratedClassesSources.txt index 82e6ae569b..cc657aebeb 100644 --- a/cmake/sources/ZigGeneratedClassesSources.txt +++ b/cmake/sources/ZigGeneratedClassesSources.txt @@ -7,6 +7,7 @@ src/bun.js/api/h2.classes.ts src/bun.js/api/html_rewriter.classes.ts src/bun.js/api/JSBundler.classes.ts src/bun.js/api/postgres.classes.ts +src/bun.js/api/ResumableSink.classes.ts src/bun.js/api/S3Client.classes.ts src/bun.js/api/S3Stat.classes.ts src/bun.js/api/server.classes.ts diff --git a/cmake/sources/ZigSources.txt b/cmake/sources/ZigSources.txt index f7d1afc43e..95a491ae41 100644 --- a/cmake/sources/ZigSources.txt +++ b/cmake/sources/ZigSources.txt @@ -271,6 +271,7 @@ src/bun.js/webcore/prompt.zig src/bun.js/webcore/ReadableStream.zig src/bun.js/webcore/Request.zig src/bun.js/webcore/Response.zig +src/bun.js/webcore/ResumableSink.zig src/bun.js/webcore/S3Client.zig src/bun.js/webcore/S3File.zig src/bun.js/webcore/S3Stat.zig @@ -522,10 +523,26 @@ src/hive_array.zig src/hmac.zig src/HTMLScanner.zig src/http.zig -src/http/header_builder.zig -src/http/method.zig -src/http/mime_type.zig -src/http/url_path.zig +src/http/AsyncHTTP.zig +src/http/CertificateInfo.zig +src/http/Decompressor.zig +src/http/Encoding.zig +src/http/FetchRedirect.zig +src/http/HeaderBuilder.zig +src/http/Headers.zig +src/http/HTTPCertError.zig +src/http/HTTPContext.zig +src/http/HTTPRequestBody.zig +src/http/HTTPThread.zig +src/http/InitError.zig +src/http/InternalState.zig +src/http/Method.zig +src/http/MimeType.zig +src/http/ProxyTunnel.zig +src/http/SendFile.zig +src/http/Signals.zig +src/http/ThreadSafeStreamBuffer.zig +src/http/URLPath.zig src/http/websocket_client.zig src/http/websocket_client/CppWebSocket.zig src/http/websocket_client/WebSocketDeflate.zig diff --git a/docs/api/s3.md b/docs/api/s3.md index 857e77ab49..3cde81fc7d 100644 --- a/docs/api/s3.md +++ b/docs/api/s3.md @@ -160,7 +160,8 @@ const writer = s3file.writer({ partSize: 5 * 1024 * 1024, }); for (let i = 0; i < 10; i++) { - await writer.write(bigFile); + writer.write(bigFile); + await writer.flush(); } await writer.end(); ``` diff --git a/misctools/http_bench.zig b/misctools/http_bench.zig index 4fea0260fa..63c6d7e172 100644 --- a/misctools/http_bench.zig +++ b/misctools/http_bench.zig @@ -12,7 +12,7 @@ const C = bun.C; const clap = @import("../src/deps/zig-clap/clap.zig"); const URL = @import("../src/url.zig").URL; -const Method = @import("../src/http/method.zig").Method; +const Method = @import("../src/http/Method.zig").Method; const ColonListType = @import("../src/cli/colon_list_type.zig").ColonListType; const HeadersTuple = ColonListType(string, noop_resolver); const path_handler = @import("../src/resolver/resolve_path.zig"); diff --git a/misctools/machbench.zig b/misctools/machbench.zig index 874f8a6c43..4e4b1549a8 100644 --- a/misctools/machbench.zig +++ b/misctools/machbench.zig @@ -14,7 +14,7 @@ const clap = @import("../src/deps/zig-clap/clap.zig"); const URL = @import("../src/url.zig").URL; const Headers = bun.http.Headers; -const Method = @import("../src/http/method.zig").Method; +const Method = @import("../src/http/Method.zig").Method; const ColonListType = @import("../src/cli/colon_list_type.zig").ColonListType; const HeadersTuple = ColonListType(string, noop_resolver); const path_handler = @import("../src/resolver/resolve_path.zig"); diff --git a/packages/bun-usockets/misc/manual.md b/packages/bun-usockets/misc/manual.md index 5fab9b3134..275d1f8151 100644 --- a/packages/bun-usockets/misc/manual.md +++ b/packages/bun-usockets/misc/manual.md @@ -95,8 +95,7 @@ WIN32_EXPORT struct us_socket_context_t *us_create_child_socket_context(int ssl, ```c /* Write up to length bytes of data. Returns actual bytes written. Will call the on_writable callback of active socket context on failure to write everything off in one go. - * Set hint msg_more if you have more immediate data to write. */ -WIN32_EXPORT int us_socket_write(int ssl, struct us_socket_t *s, const char *data, int length, int msg_more); +WIN32_EXPORT int us_socket_write(int ssl, struct us_socket_t *s, const char *data, int length); /* Set a low precision, high performance timer on a socket. A socket can only have one single active timer at any given point in time. Will remove any such pre set timer */ WIN32_EXPORT void us_socket_timeout(int ssl, struct us_socket_t *s, unsigned int seconds); diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index f4d4bc31c9..6c81929cfb 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -762,9 +762,9 @@ ssize_t bsd_write2(LIBUS_SOCKET_DESCRIPTOR fd, const char *header, int header_le } #else ssize_t bsd_write2(LIBUS_SOCKET_DESCRIPTOR fd, const char *header, int header_length, const char *payload, int payload_length) { - ssize_t written = bsd_send(fd, header, header_length, 0); + ssize_t written = bsd_send(fd, header, header_length); if (written == header_length) { - ssize_t second_write = bsd_send(fd, payload, payload_length, 0); + ssize_t second_write = bsd_send(fd, payload, payload_length); if (second_write > 0) { written += second_write; } @@ -773,7 +773,7 @@ ssize_t bsd_write2(LIBUS_SOCKET_DESCRIPTOR fd, const char *header, int header_le } #endif -ssize_t bsd_send(LIBUS_SOCKET_DESCRIPTOR fd, const char *buf, int length, int msg_more) { +ssize_t bsd_send(LIBUS_SOCKET_DESCRIPTOR fd, const char *buf, int length) { while (1) { // MSG_MORE (Linux), MSG_PARTIAL (Windows), TCP_NOPUSH (BSD) @@ -781,13 +781,8 @@ ssize_t bsd_send(LIBUS_SOCKET_DESCRIPTOR fd, const char *buf, int length, int ms #define MSG_NOSIGNAL 0 #endif - #ifdef MSG_MORE - // for Linux we do not want signals - ssize_t rc = send(fd, buf, length, ((msg_more != 0) * MSG_MORE) | MSG_NOSIGNAL | MSG_DONTWAIT); - #else - // use TCP_NOPUSH - ssize_t rc = send(fd, buf, length, MSG_NOSIGNAL | MSG_DONTWAIT); - #endif + // use TCP_NOPUSH + ssize_t rc = send(fd, buf, length, MSG_NOSIGNAL | MSG_DONTWAIT); if (UNLIKELY(IS_EINTR(rc))) { continue; diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index dff3399fb6..7e8c712555 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -52,10 +52,6 @@ struct loop_ssl_data { unsigned int ssl_read_input_offset; struct us_socket_t *ssl_socket; - - int last_write_was_msg_more; - int msg_more; - BIO *shared_rbio; BIO *shared_wbio; BIO_METHOD *shared_biom; @@ -139,10 +135,7 @@ int BIO_s_custom_write(BIO *bio, const char *data, int length) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)BIO_get_data(bio); - loop_ssl_data->last_write_was_msg_more = - loop_ssl_data->msg_more || length == 16413; - int written = us_socket_write(0, loop_ssl_data->ssl_socket, data, length, - loop_ssl_data->last_write_was_msg_more); + int written = us_socket_write(0, loop_ssl_data->ssl_socket, data, length); BIO_clear_retry_flags(bio); if (!written) { @@ -192,7 +185,6 @@ struct loop_ssl_data * us_internal_set_loop_ssl_data(struct us_internal_ssl_sock loop_ssl_data->ssl_read_input_length = 0; loop_ssl_data->ssl_read_input_offset = 0; loop_ssl_data->ssl_socket = &s->s; - loop_ssl_data->msg_more = 0; return loop_ssl_data; } @@ -665,8 +657,6 @@ void us_internal_init_loop_ssl_data(struct us_loop_t *loop) { us_calloc(1, sizeof(struct loop_ssl_data)); loop_ssl_data->ssl_read_input_length = 0; loop_ssl_data->ssl_read_input_offset = 0; - loop_ssl_data->last_write_was_msg_more = 0; - loop_ssl_data->msg_more = 0; loop_ssl_data->ssl_read_output = us_malloc(LIBUS_RECV_BUFFER_LENGTH + LIBUS_RECV_BUFFER_PADDING * 2); @@ -1741,17 +1731,16 @@ us_internal_ssl_socket_get_native_handle(struct us_internal_ssl_socket_t *s) { } int us_internal_ssl_socket_raw_write(struct us_internal_ssl_socket_t *s, - const char *data, int length, - int msg_more) { + const char *data, int length) { if (us_socket_is_closed(0, &s->s) || us_internal_ssl_socket_is_shut_down(s)) { return 0; } - return us_socket_write(0, &s->s, data, length, msg_more); + return us_socket_write(0, &s->s, data, length); } int us_internal_ssl_socket_write(struct us_internal_ssl_socket_t *s, - const char *data, int length, int msg_more) { + const char *data, int length) { if (us_socket_is_closed(0, &s->s) || us_internal_ssl_socket_is_shut_down(s) || length == 0) { return 0; @@ -1772,14 +1761,8 @@ int us_internal_ssl_socket_write(struct us_internal_ssl_socket_t *s, loop_ssl_data->ssl_read_input_length = 0; loop_ssl_data->ssl_socket = &s->s; - loop_ssl_data->msg_more = msg_more; - loop_ssl_data->last_write_was_msg_more = 0; int written = SSL_write(s->ssl, data, length); - loop_ssl_data->msg_more = 0; - if (loop_ssl_data->last_write_was_msg_more && !msg_more) { - us_socket_flush(0, &s->s); - } if (written > 0) { return written; @@ -1836,7 +1819,6 @@ void us_internal_ssl_socket_shutdown(struct us_internal_ssl_socket_t *s) { // on_data and checked in the BIO loop_ssl_data->ssl_socket = &s->s; - loop_ssl_data->msg_more = 0; // sets SSL_SENT_SHUTDOWN and waits for the other side to do the same int ret = SSL_shutdown(s->ssl); diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 5af0d56e2f..1989d5d58d 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -421,10 +421,9 @@ struct us_socket_t *us_internal_ssl_socket_context_connect_unix( size_t pathlen, int options, int socket_ext_size); int us_internal_ssl_socket_write(us_internal_ssl_socket_r s, - const char *data, int length, int msg_more); + const char *data, int length); int us_internal_ssl_socket_raw_write(us_internal_ssl_socket_r s, - const char *data, int length, - int msg_more); + const char *data, int length); void us_internal_ssl_socket_timeout(us_internal_ssl_socket_r s, unsigned int seconds); diff --git a/packages/bun-usockets/src/internal/networking/bsd.h b/packages/bun-usockets/src/internal/networking/bsd.h index c10b96785e..699aeffa92 100644 --- a/packages/bun-usockets/src/internal/networking/bsd.h +++ b/packages/bun-usockets/src/internal/networking/bsd.h @@ -210,7 +210,7 @@ ssize_t bsd_recv(LIBUS_SOCKET_DESCRIPTOR fd, void *buf, int length, int flags); #if !defined(_WIN32) ssize_t bsd_recvmsg(LIBUS_SOCKET_DESCRIPTOR fd, struct msghdr *msg, int flags); #endif -ssize_t bsd_send(LIBUS_SOCKET_DESCRIPTOR fd, const char *buf, int length, int msg_more); +ssize_t bsd_send(LIBUS_SOCKET_DESCRIPTOR fd, const char *buf, int length); #if !defined(_WIN32) ssize_t bsd_sendmsg(LIBUS_SOCKET_DESCRIPTOR fd, const struct msghdr *msg, int flags); #endif diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 6128d855f1..7bb0cd0b53 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -419,9 +419,8 @@ struct us_poll_t *us_poll_resize(us_poll_r p, us_loop_r loop, unsigned int ext_s void *us_socket_get_native_handle(int ssl, us_socket_r s) nonnull_fn_decl; /* Write up to length bytes of data. Returns actual bytes written. - * Will call the on_writable callback of active socket context on failure to write everything off in one go. - * Set hint msg_more if you have more immediate data to write. */ -int us_socket_write(int ssl, us_socket_r s, const char * nonnull_arg data, int length, int msg_more) nonnull_fn_decl; + * Will call the on_writable callback of active socket context on failure to write everything off in one go. */ +int us_socket_write(int ssl, us_socket_r s, const char * nonnull_arg data, int length) nonnull_fn_decl; /* Special path for non-SSL sockets. Used to send header and payload in one go. Works like us_socket_write. */ int us_socket_write2(int ssl, us_socket_r s, const char *header, int header_length, const char *payload, int payload_length) nonnull_fn_decl; @@ -440,7 +439,7 @@ void *us_connecting_socket_ext(int ssl, struct us_connecting_socket_t *c) nonnul /* Return the socket context of this socket */ struct us_socket_context_t *us_socket_context(int ssl, us_socket_r s) nonnull_fn_decl __attribute__((returns_nonnull)); -/* Withdraw any msg_more status and flush any pending data */ +/* Flush any pending data */ void us_socket_flush(int ssl, us_socket_r s) nonnull_fn_decl; /* Shuts down the connection by sending FIN and/or close_notify */ @@ -471,7 +470,7 @@ void us_socket_local_address(int ssl, us_socket_r s, char *nonnull_arg buf, int struct us_socket_t *us_socket_pair(struct us_socket_context_t *ctx, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR* fds); struct us_socket_t *us_socket_from_fd(struct us_socket_context_t *ctx, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR fd, int ipc); struct us_socket_t *us_socket_wrap_with_tls(int ssl, us_socket_r s, struct us_bun_socket_context_options_t options, struct us_socket_events_t events, int socket_ext_size); -int us_socket_raw_write(int ssl, us_socket_r s, const char *data, int length, int msg_more); +int us_socket_raw_write(int ssl, us_socket_r s, const char *data, int length); struct us_socket_t* us_socket_open(int ssl, struct us_socket_t * s, int is_client, char* ip, int ip_length); int us_raw_root_certs(struct us_cert_string_t**out); unsigned int us_get_remote_address_info(char *buf, us_socket_r s, const char **dest, int *port, int *is_ipv6); diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 1d49d2fe77..8b3a8723e3 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -357,17 +357,17 @@ void *us_connecting_socket_get_native_handle(int ssl, struct us_connecting_socke return (void *) (uintptr_t) -1; } -int us_socket_write(int ssl, struct us_socket_t *s, const char *data, int length, int msg_more) { +int us_socket_write(int ssl, struct us_socket_t *s, const char *data, int length) { #ifndef LIBUS_NO_SSL if (ssl) { - return us_internal_ssl_socket_write((struct us_internal_ssl_socket_t *) s, data, length, msg_more); + return us_internal_ssl_socket_write((struct us_internal_ssl_socket_t *) s, data, length); } #endif if (us_socket_is_closed(ssl, s) || us_socket_is_shut_down(ssl, s)) { return 0; } - int written = bsd_send(us_poll_fd(&s->p), data, length, msg_more); + int written = bsd_send(us_poll_fd(&s->p), data, length); if (written != length) { s->context->loop->data.last_write_failed = 1; us_poll_change(&s->p, s->context->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); @@ -495,14 +495,14 @@ struct us_socket_t* us_socket_open(int ssl, struct us_socket_t * s, int is_clien } -int us_socket_raw_write(int ssl, struct us_socket_t *s, const char *data, int length, int msg_more) { +int us_socket_raw_write(int ssl, struct us_socket_t *s, const char *data, int length) { #ifndef LIBUS_NO_SSL if (ssl) { - return us_internal_ssl_socket_raw_write((struct us_internal_ssl_socket_t *) s, data, length, msg_more); + return us_internal_ssl_socket_raw_write((struct us_internal_ssl_socket_t *) s, data, length); } #endif // non-TLS is always raw - return us_socket_write(ssl, s, data, length, msg_more); + return us_socket_write(ssl, s, data, length); } unsigned int us_get_remote_address_info(char *buf, struct us_socket_t *s, const char **dest, int *port, int *is_ipv6) diff --git a/packages/bun-uws/src/AsyncSocket.h b/packages/bun-uws/src/AsyncSocket.h index 0782794338..e5bcf5cabb 100644 --- a/packages/bun-uws/src/AsyncSocket.h +++ b/packages/bun-uws/src/AsyncSocket.h @@ -247,7 +247,7 @@ public: int max_flush_len = std::min(buffer_len, (size_t)INT_MAX); /* Attempt to write data to the socket */ - int written = us_socket_write(SSL, (us_socket_t *) this, asyncSocketData->buffer.data(), max_flush_len, 0); + int written = us_socket_write(SSL, (us_socket_t *) this, asyncSocketData->buffer.data(), max_flush_len); total_written += written; /* Check if we couldn't write the entire buffer */ @@ -297,7 +297,7 @@ public: int max_flush_len = std::min(buffer_len, (size_t)INT_MAX); /* Write off as much as we can */ - int written = us_socket_write(SSL, (us_socket_t *) this, asyncSocketData->buffer.data(), max_flush_len, /*nextLength != 0 | */length); + int written = us_socket_write(SSL, (us_socket_t *) this, asyncSocketData->buffer.data(), max_flush_len); /* On failure return, otherwise continue down the function */ if ((unsigned int) written < buffer_len) { /* Update buffering (todo: we can do better here if we keep track of what happens to this guy later on) */ @@ -342,7 +342,7 @@ public: } } else { /* We are not corked */ - int written = us_socket_write(SSL, (us_socket_t *) this, src, length, nextLength != 0); + int written = us_socket_write(SSL, (us_socket_t *) this, src, length); /* Did we fail? */ if (written < length) { diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index a5b89123bb..0fc7cf9f56 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -383,7 +383,7 @@ private: httpContextData->onClientError(SSL, s, result.parserError, data, length); } /* For errors, we only deliver them "at most once". We don't care if they get halfways delivered or not. */ - us_socket_write(SSL, s, httpErrorResponses[httpErrorStatusCode].data(), (int) httpErrorResponses[httpErrorStatusCode].length(), false); + us_socket_write(SSL, s, httpErrorResponses[httpErrorStatusCode].data(), (int) httpErrorResponses[httpErrorStatusCode].length()); us_socket_shutdown(SSL, s); /* Close any socket on HTTP errors */ us_socket_close(SSL, s, 0, nullptr); diff --git a/scripts/buildkite-failures.ts b/scripts/buildkite-failures.ts index d042261e10..fa506f83f4 100755 --- a/scripts/buildkite-failures.ts +++ b/scripts/buildkite-failures.ts @@ -5,7 +5,7 @@ import { existsSync } from "fs"; import { resolve } from "path"; // Check if we're in a TTY for color support -const isTTY = process.stdout.isTTY || process.env.FORCE_COLOR === '1'; +const isTTY = process.stdout.isTTY || process.env.FORCE_COLOR === "1"; // Get git root directory let gitRoot = process.cwd(); @@ -21,36 +21,36 @@ function fileToUrl(filePath) { // Extract just the file path without line numbers or other info const match = filePath.match(/^([^\s:]+\.(ts|js|tsx|jsx|zig))/); if (!match) return filePath; - + const cleanPath = match[1]; const fullPath = resolve(gitRoot, cleanPath); - + if (existsSync(fullPath)) { return `file://${fullPath}`; } } catch (error) { // If anything fails, just return the original path } - + return filePath; } // Color codes - simpler color scheme const colors = { - reset: isTTY ? '\x1b[0m' : '', - bold: isTTY ? '\x1b[1m' : '', - dim: isTTY ? '\x1b[2m' : '', - red: isTTY ? '\x1b[31m' : '', - green: isTTY ? '\x1b[32m' : '', - bgBlue: isTTY ? '\x1b[44m' : '', - bgRed: isTTY ? '\x1b[41m' : '', - white: isTTY ? '\x1b[97m' : '', + reset: isTTY ? "\x1b[0m" : "", + bold: isTTY ? "\x1b[1m" : "", + dim: isTTY ? "\x1b[2m" : "", + red: isTTY ? "\x1b[31m" : "", + green: isTTY ? "\x1b[32m" : "", + bgBlue: isTTY ? "\x1b[44m" : "", + bgRed: isTTY ? "\x1b[41m" : "", + white: isTTY ? "\x1b[97m" : "", }; // Parse command line arguments const args = process.argv.slice(2); -const showWarnings = args.includes('--warnings') || args.includes('-w'); -const showFlaky = args.includes('--flaky') || args.includes('-f'); +const showWarnings = args.includes("--warnings") || args.includes("-w"); +const showFlaky = args.includes("--flaky") || args.includes("-f"); const inputArg = args.find(arg => !arg.startsWith("-")); // Determine what type of input we have @@ -59,14 +59,14 @@ let branch = null; if (inputArg) { // BuildKite URL - if (inputArg.includes('buildkite.com')) { + if (inputArg.includes("buildkite.com")) { const buildMatch = inputArg.match(/builds\/(\d+)/); if (buildMatch) { buildNumber = buildMatch[1]; } } // GitHub PR URL - else if (inputArg.includes('github.com') && inputArg.includes('/pull/')) { + else if (inputArg.includes("github.com") && inputArg.includes("/pull/")) { const prMatch = inputArg.match(/pull\/(\d+)/); if (prMatch) { // Fetch PR info from GitHub API @@ -80,7 +80,7 @@ if (inputArg) { } // Plain number or #number - assume it's a GitHub PR else if (/^#?\d+$/.test(inputArg)) { - const prNumber = inputArg.replace('#', ''); + const prNumber = inputArg.replace("#", ""); const prResponse = await fetch(`https://api.github.com/repos/oven-sh/bun/pulls/${prNumber}`); if (prResponse.ok) { const pr = await prResponse.json(); @@ -105,12 +105,12 @@ if (!buildNumber) { const response = await fetch(buildsUrl); const html = await response.text(); const match = html.match(/\/bun\/bun\/builds\/(\d+)/); - + if (!match) { console.log(`No builds found for branch: ${branch}`); process.exit(0); } - + buildNumber = match[1]; } @@ -129,13 +129,13 @@ const diffDays = Math.floor(diffHours / 24); let timeAgo; if (diffDays > 0) { - timeAgo = `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`; + timeAgo = `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`; } else if (diffHours > 0) { - timeAgo = `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; + timeAgo = `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`; } else if (diffMins > 0) { - timeAgo = `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; + timeAgo = `${diffMins} minute${diffMins !== 1 ? "s" : ""} ago`; } else { - timeAgo = `${diffSecs} second${diffSecs !== 1 ? 's' : ''} ago`; + timeAgo = `${diffSecs} second${diffSecs !== 1 ? "s" : ""} ago`; } console.log(`${timeAgo} - build #${buildNumber} https://buildkite.com/bun/bun/builds/${buildNumber}\n`); @@ -147,22 +147,19 @@ if (build.state === "passed") { } // Get failed jobs -const failedJobs = build.jobs?.filter(job => - job.exit_status && job.exit_status > 0 && - !job.soft_failed && - job.type === "script" -) || []; +const failedJobs = + build.jobs?.filter(job => job.exit_status && job.exit_status > 0 && !job.soft_failed && job.type === "script") || []; // Platform emoji mapping const platformMap = { - 'darwin': '🍎', - 'macos': '🍎', - 'ubuntu': '🐧', - 'debian': '🐧', - 'alpine': '🐧', - 'linux': '🐧', - 'windows': '🪟', - 'win': '🪟', + "darwin": "🍎", + "macos": "🍎", + "ubuntu": "🐧", + "debian": "🐧", + "alpine": "🐧", + "linux": "🐧", + "windows": "🪟", + "win": "🪟", }; // Fetch annotations by scraping the build page @@ -173,18 +170,17 @@ const pageHtml = await pageResponse.text(); let annotationsData = null; const scriptContents: string[] = []; -const scriptRewriter = new HTMLRewriter() - .on('script', { - text(text) { - scriptContents.push(text.text); - } - }); +const scriptRewriter = new HTMLRewriter().on("script", { + text(text) { + scriptContents.push(text.text); + }, +}); await new Response(scriptRewriter.transform(new Response(pageHtml))).text(); // Find the registerRequest call in script contents -const fullScript = scriptContents.join(''); -let registerRequestIndex = fullScript.indexOf('registerRequest'); +const fullScript = scriptContents.join(""); +let registerRequestIndex = fullScript.indexOf("registerRequest"); // Find the AnnotationsListRendererQuery after registerRequest if (registerRequestIndex !== -1) { @@ -200,46 +196,46 @@ if (registerRequestIndex !== -1) { try { // Find the start of the JSON object (after the comma and any whitespace) let jsonStart = registerRequestIndex; - + // Skip to the opening brace, accounting for the function name and first parameter let commaFound = false; for (let i = registerRequestIndex; i < fullScript.length; i++) { - if (fullScript[i] === ',' && !commaFound) { + if (fullScript[i] === "," && !commaFound) { commaFound = true; - } else if (commaFound && fullScript[i] === '{') { + } else if (commaFound && fullScript[i] === "{") { jsonStart = i; break; } } - + // Find the matching closing brace, considering strings let braceCount = 0; let jsonEnd = jsonStart; let inString = false; let escapeNext = false; - + for (let i = jsonStart; i < fullScript.length; i++) { const char = fullScript[i]; - + if (escapeNext) { escapeNext = false; continue; } - - if (char === '\\') { + + if (char === "\\") { escapeNext = true; continue; } - + if (char === '"' && !inString) { inString = true; } else if (char === '"' && inString) { inString = false; } - + if (!inString) { - if (char === '{') braceCount++; - else if (char === '}') { + if (char === "{") braceCount++; + else if (char === "}") { braceCount--; if (braceCount === 0) { jsonEnd = i + 1; @@ -248,58 +244,60 @@ if (registerRequestIndex !== -1) { } } } - + const jsonString = fullScript.substring(jsonStart, jsonEnd); annotationsData = JSON.parse(jsonString); const edges = annotationsData?.build?.annotations?.edges || []; - + // Just collect all unique annotations by context const annotationsByContext = new Map(); - + for (const edge of edges) { const node = edge.node; if (!node || !node.context) continue; - + // Skip if we already have this context if (annotationsByContext.has(node.context)) { continue; } - + annotationsByContext.set(node.context, { context: node.context, - html: node.body?.html || '' + html: node.body?.html || "", }); } - + // Collect annotations const annotations = Array.from(annotationsByContext.values()); - + // Group annotations by test file to detect duplicates const annotationsByFile = new Map(); const nonFileAnnotations = []; - + for (const annotation of annotations) { // Check if this is a file-based annotation const isFileAnnotation = annotation.context.match(/\.(ts|js|tsx|jsx|zig)$/); - + if (isFileAnnotation) { // Parse the HTML to extract all platform sections - const html = annotation.html || ''; - + const html = annotation.html || ""; + // Check if this annotation contains multiple
sections (one per platform) const detailsSections = html.match(/
[\s\S]*?<\/details>/g); - + if (detailsSections && detailsSections.length > 1) { // Multiple platform failures in one annotation for (const section of detailsSections) { - const summaryMatch = section.match(/[\s\S]*?]+>([^<]+)<\/code><\/a>\s*-\s*(\d+\s+\w+)\s+on\s+]+>([\s\S]+?)<\/a>/); - + const summaryMatch = section.match( + /[\s\S]*?]+>([^<]+)<\/code><\/a>\s*-\s*(\d+\s+\w+)\s+on\s+]+>([\s\S]+?)<\/a>/, + ); + if (summaryMatch) { const filePath = summaryMatch[1]; const failureInfo = summaryMatch[2]; const platformHtml = summaryMatch[3]; - const platform = platformHtml.replace(/]+>/g, '').trim(); - + const platform = platformHtml.replace(/]+>/g, "").trim(); + const fileKey = `${filePath}|${failureInfo}`; if (!annotationsByFile.has(fileKey)) { annotationsByFile.set(fileKey, { @@ -307,30 +305,32 @@ if (registerRequestIndex !== -1) { failureInfo, platforms: [], htmlParts: [], - originalAnnotations: [] + originalAnnotations: [], }); } - + const entry = annotationsByFile.get(fileKey); entry.platforms.push(platform); entry.htmlParts.push(section); entry.originalAnnotations.push({ ...annotation, html: section, - originalHtml: html + originalHtml: html, }); } } } else { // Single platform failure - const summaryMatch = html.match(/[\s\S]*?]+>([^<]+)<\/code><\/a>\s*-\s*(\d+\s+\w+)\s+on\s+]+>([\s\S]+?)<\/a>/); - + const summaryMatch = html.match( + /[\s\S]*?]+>([^<]+)<\/code><\/a>\s*-\s*(\d+\s+\w+)\s+on\s+]+>([\s\S]+?)<\/a>/, + ); + if (summaryMatch) { const filePath = summaryMatch[1]; const failureInfo = summaryMatch[2]; const platformHtml = summaryMatch[3]; - const platform = platformHtml.replace(/]+>/g, '').trim(); - + const platform = platformHtml.replace(/]+>/g, "").trim(); + const fileKey = `${filePath}|${failureInfo}`; if (!annotationsByFile.has(fileKey)) { annotationsByFile.set(fileKey, { @@ -338,10 +338,10 @@ if (registerRequestIndex !== -1) { failureInfo, platforms: [], htmlParts: [], - originalAnnotations: [] + originalAnnotations: [], }); } - + const entry = annotationsByFile.get(fileKey); entry.platforms.push(platform); entry.htmlParts.push(html); @@ -356,25 +356,25 @@ if (registerRequestIndex !== -1) { nonFileAnnotations.push(annotation); } } - + // Create merged annotations const mergedAnnotations = []; - + // Add file-based annotations for (const [key, entry] of annotationsByFile) { const { filePath, failureInfo, platforms, htmlParts, originalAnnotations } = entry; - + // If we have multiple platforms with the same content, merge them if (platforms.length > 1) { // Create context string with all platforms const uniquePlatforms = [...new Set(platforms)]; - const context = `${filePath} - ${failureInfo} on ${uniquePlatforms.join(', ')}`; - + const context = `${filePath} - ${failureInfo} on ${uniquePlatforms.join(", ")}`; + // Check if all HTML parts are identical const firstHtml = htmlParts[0]; const allSame = htmlParts.every(html => html === firstHtml); - - let mergedHtml = ''; + + let mergedHtml = ""; if (allSame) { // If all the same, just use the first one mergedHtml = firstHtml; @@ -382,7 +382,7 @@ if (registerRequestIndex !== -1) { // If different, try to find one with the most color spans let bestHtml = firstHtml; let maxColorCount = (firstHtml.match(/term-fg/g) || []).length; - + for (const html of htmlParts) { const colorCount = (html.match(/term-fg/g) || []).length; if (colorCount > maxColorCount) { @@ -392,46 +392,46 @@ if (registerRequestIndex !== -1) { } mergedHtml = bestHtml; } - + mergedAnnotations.push({ context, html: mergedHtml, merged: true, - platformCount: uniquePlatforms.length + platformCount: uniquePlatforms.length, }); } else { // Single platform, use original mergedAnnotations.push(originalAnnotations[0]); } } - + // Add non-file annotations mergedAnnotations.push(...nonFileAnnotations); - + // Sort annotations: ones with colors at the bottom const annotationsWithColorInfo = mergedAnnotations.map(annotation => { - const html = annotation.html || ''; - const hasColors = html.includes('term-fg') || html.includes('\\x1b['); + const html = annotation.html || ""; + const hasColors = html.includes("term-fg") || html.includes("\\x1b["); return { annotation, hasColors }; }); - + // Sort: no colors first, then colors annotationsWithColorInfo.sort((a, b) => { if (a.hasColors === b.hasColors) return 0; return a.hasColors ? 1 : -1; }); - + const sortedAnnotations = annotationsWithColorInfo.map(item => item.annotation); - + // Count failures - look for actual test counts in the content let totalFailures = 0; let totalFlaky = 0; - + // First try to count from annotations for (const annotation of sortedAnnotations) { - const isFlaky = annotation.context.toLowerCase().includes('flaky'); - const html = annotation.html || ''; - + const isFlaky = annotation.context.toLowerCase().includes("flaky"); + const html = annotation.html || ""; + // Look for patterns like "X tests failed" or "X failing" const failureMatches = html.match(/(\d+)\s+(tests?\s+failed|failing)/gi); if (failureMatches) { @@ -449,12 +449,12 @@ if (registerRequestIndex !== -1) { totalFailures++; } } - + // If no annotations, use job count if (totalFailures === 0 && failedJobs.length > 0) { totalFailures = failedJobs.length; } - + // Display failure count if (totalFailures > 0 || totalFlaky > 0) { if (totalFailures > 0) { @@ -467,15 +467,15 @@ if (registerRequestIndex !== -1) { } else if (failedJobs.length > 0) { console.log(`\n${colors.red}${colors.bold}${failedJobs.length} job failures${colors.reset}\n`); } - + // Display all annotations console.log(); for (const annotation of sortedAnnotations) { // Skip flaky tests unless --flaky flag is set - if (!showFlaky && annotation.context.toLowerCase().includes('flaky')) { + if (!showFlaky && annotation.context.toLowerCase().includes("flaky")) { continue; } - + // Display context header with background color // For merged annotations, show platform info if (annotation.merged && annotation.platformCount) { @@ -484,7 +484,9 @@ if (registerRequestIndex !== -1) { if (contextParts) { const [, filename, failureInfo, platformsStr] = contextParts; const fileUrl = fileToUrl(filename); - console.log(`${colors.bgBlue}${colors.white}${colors.bold} ${fileUrl} - ${failureInfo} ${colors.reset} ${colors.dim}on ${platformsStr}${colors.reset}`); + console.log( + `${colors.bgBlue}${colors.white}${colors.bold} ${fileUrl} - ${failureInfo} ${colors.reset} ${colors.dim}on ${platformsStr}${colors.reset}`, + ); } else { const fileUrl = fileToUrl(annotation.context); console.log(`${colors.bgBlue}${colors.white}${colors.bold} ${fileUrl} ${colors.reset}`); @@ -492,163 +494,164 @@ if (registerRequestIndex !== -1) { } else { // Single annotation - need to extract platform info from HTML const fileUrl = fileToUrl(annotation.context); - + // Try to extract platform info from the HTML for single platform tests - const html = annotation.html || ''; - const singlePlatformMatch = html.match(/[\s\S]*?]+>([^<]+)<\/code><\/a>\s*-\s*(\d+\s+\w+)\s+on\s+]+>([\s\S]+?)<\/a>/); - + const html = annotation.html || ""; + const singlePlatformMatch = html.match( + /[\s\S]*?]+>([^<]+)<\/code><\/a>\s*-\s*(\d+\s+\w+)\s+on\s+]+>([\s\S]+?)<\/a>/, + ); + if (singlePlatformMatch) { const failureInfo = singlePlatformMatch[2]; const platformHtml = singlePlatformMatch[3]; - const platform = platformHtml.replace(/]+>/g, '').trim(); - console.log(`${colors.bgBlue}${colors.white}${colors.bold} ${fileUrl} - ${failureInfo} ${colors.reset} ${colors.dim}on ${platform}${colors.reset}`); + const platform = platformHtml.replace(/]+>/g, "").trim(); + console.log( + `${colors.bgBlue}${colors.white}${colors.bold} ${fileUrl} - ${failureInfo} ${colors.reset} ${colors.dim}on ${platform}${colors.reset}`, + ); } else { console.log(`${colors.bgBlue}${colors.white}${colors.bold} ${fileUrl} ${colors.reset}`); } } console.log(); - + // Process the annotation HTML to preserve colors - const html = annotation.html || ''; - - + const html = annotation.html || ""; + // First unescape unicode sequences let unescapedHtml = html - .replace(/\\u003c/g, '<') - .replace(/\\u003e/g, '>') - .replace(/\\u0026/g, '&') + .replace(/\\u003c/g, "<") + .replace(/\\u003e/g, ">") + .replace(/\\u0026/g, "&") .replace(/\\"/g, '"') .replace(/\\'/g, "'") - .replace(/\\u001b/g, '\x1b'); // Unescape ANSI escape sequences - + .replace(/\\u001b/g, "\x1b"); // Unescape ANSI escape sequences + // Handle newlines more carefully - BuildKite sometimes has actual newlines that shouldn't be there // Only replace \n if it's actually an escaped newline, not part of the content - unescapedHtml = unescapedHtml.replace(/\\n/g, '\n'); - + unescapedHtml = unescapedHtml.replace(/\\n/g, "\n"); + // Also handle escaped ANSI sequences that might appear as \\x1b or \033 - unescapedHtml = unescapedHtml - .replace(/\\\\x1b/g, '\x1b') - .replace(/\\033/g, '\x1b'); - + unescapedHtml = unescapedHtml.replace(/\\\\x1b/g, "\x1b").replace(/\\033/g, "\x1b"); + // Convert HTML with ANSI color classes to actual ANSI codes const termColors = { // Standard colors (0-7) - 'term-fg0': '\x1b[30m', // black - 'term-fg1': '\x1b[31m', // red - 'term-fg2': '\x1b[32m', // green - 'term-fg3': '\x1b[33m', // yellow - 'term-fg4': '\x1b[34m', // blue - 'term-fg5': '\x1b[35m', // magenta - 'term-fg6': '\x1b[36m', // cyan - 'term-fg7': '\x1b[37m', // white + "term-fg0": "\x1b[30m", // black + "term-fg1": "\x1b[31m", // red + "term-fg2": "\x1b[32m", // green + "term-fg3": "\x1b[33m", // yellow + "term-fg4": "\x1b[34m", // blue + "term-fg5": "\x1b[35m", // magenta + "term-fg6": "\x1b[36m", // cyan + "term-fg7": "\x1b[37m", // white // Also support 30-37 format - 'term-fg30': '\x1b[30m', // black - 'term-fg31': '\x1b[31m', // red - 'term-fg32': '\x1b[32m', // green - 'term-fg33': '\x1b[33m', // yellow - 'term-fg34': '\x1b[34m', // blue - 'term-fg35': '\x1b[35m', // magenta - 'term-fg36': '\x1b[36m', // cyan - 'term-fg37': '\x1b[37m', // white + "term-fg30": "\x1b[30m", // black + "term-fg31": "\x1b[31m", // red + "term-fg32": "\x1b[32m", // green + "term-fg33": "\x1b[33m", // yellow + "term-fg34": "\x1b[34m", // blue + "term-fg35": "\x1b[35m", // magenta + "term-fg36": "\x1b[36m", // cyan + "term-fg37": "\x1b[37m", // white // Bright colors with 'i' prefix - 'term-fgi90': '\x1b[90m', // bright black - 'term-fgi91': '\x1b[91m', // bright red - 'term-fgi92': '\x1b[92m', // bright green - 'term-fgi93': '\x1b[93m', // bright yellow - 'term-fgi94': '\x1b[94m', // bright blue - 'term-fgi95': '\x1b[95m', // bright magenta - 'term-fgi96': '\x1b[96m', // bright cyan - 'term-fgi97': '\x1b[97m', // bright white + "term-fgi90": "\x1b[90m", // bright black + "term-fgi91": "\x1b[91m", // bright red + "term-fgi92": "\x1b[92m", // bright green + "term-fgi93": "\x1b[93m", // bright yellow + "term-fgi94": "\x1b[94m", // bright blue + "term-fgi95": "\x1b[95m", // bright magenta + "term-fgi96": "\x1b[96m", // bright cyan + "term-fgi97": "\x1b[97m", // bright white // Also support without 'i' - 'term-fg90': '\x1b[90m', // bright black - 'term-fg91': '\x1b[91m', // bright red - 'term-fg92': '\x1b[92m', // bright green - 'term-fg93': '\x1b[93m', // bright yellow - 'term-fg94': '\x1b[94m', // bright blue - 'term-fg95': '\x1b[95m', // bright magenta - 'term-fg96': '\x1b[96m', // bright cyan - 'term-fg97': '\x1b[97m', // bright white + "term-fg90": "\x1b[90m", // bright black + "term-fg91": "\x1b[91m", // bright red + "term-fg92": "\x1b[92m", // bright green + "term-fg93": "\x1b[93m", // bright yellow + "term-fg94": "\x1b[94m", // bright blue + "term-fg95": "\x1b[95m", // bright magenta + "term-fg96": "\x1b[96m", // bright cyan + "term-fg97": "\x1b[97m", // bright white // Background colors - 'term-bg40': '\x1b[40m', // black - 'term-bg41': '\x1b[41m', // red - 'term-bg42': '\x1b[42m', // green - 'term-bg43': '\x1b[43m', // yellow - 'term-bg44': '\x1b[44m', // blue - 'term-bg45': '\x1b[45m', // magenta - 'term-bg46': '\x1b[46m', // cyan - 'term-bg47': '\x1b[47m', // white + "term-bg40": "\x1b[40m", // black + "term-bg41": "\x1b[41m", // red + "term-bg42": "\x1b[42m", // green + "term-bg43": "\x1b[43m", // yellow + "term-bg44": "\x1b[44m", // blue + "term-bg45": "\x1b[45m", // magenta + "term-bg46": "\x1b[46m", // cyan + "term-bg47": "\x1b[47m", // white // Text styles - 'term-bold': '\x1b[1m', - 'term-dim': '\x1b[2m', - 'term-italic': '\x1b[3m', - 'term-underline': '\x1b[4m', + "term-bold": "\x1b[1m", + "term-dim": "\x1b[2m", + "term-italic": "\x1b[3m", + "term-underline": "\x1b[4m", }; - + let text = unescapedHtml; - - + // Convert color spans to ANSI codes if TTY if (isTTY) { // Convert spans with color classes to ANSI codes for (const [className, ansiCode] of Object.entries(termColors)) { // Match spans that contain the class name (might have multiple classes) // Need to handle both formats: and - const regex = new RegExp(`]*class="[^"]*\\b${className}\\b[^"]*"[^>]*>([\\s\\S]*?)`, 'g'); + const regex = new RegExp(`]*class="[^"]*\\b${className}\\b[^"]*"[^>]*>([\\s\\S]*?)`, "g"); text = text.replace(regex, (match, content) => { // Don't add reset if the content already has ANSI codes - if (content.includes('\x1b[')) { + if (content.includes("\x1b[")) { return `${ansiCode}${content}`; } return `${ansiCode}${content}${colors.reset}`; }); } } - + // Check if we already have ANSI codes in the text after processing - const hasExistingAnsi = text.includes('\x1b['); - + const hasExistingAnsi = text.includes("\x1b["); + // Check for broken color patterns (single characters wrapped in colors) // If we see patterns like green[, red text, green], it's likely broken // Also check for patterns like: green[, then reset, then text, then red text, then reset, then green] - const hasBrokenColors = text.includes('\x1b[32m[') || text.includes('\x1b[32m]') || - (text.includes('\x1b[32m✓') && text.includes('\x1b[31m') && text.includes('ms]')); - + const hasBrokenColors = + text.includes("\x1b[32m[") || + text.includes("\x1b[32m]") || + (text.includes("\x1b[32m✓") && text.includes("\x1b[31m") && text.includes("ms]")); + if (hasBrokenColors) { // Remove all ANSI codes if the coloring looks broken - text = text.replace(/\x1b\[[0-9;]*m/g, ''); + text = text.replace(/\x1b\[[0-9;]*m/g, ""); } - + // Remove all HTML tags, but be careful with existing ANSI codes text = text - .replace(/]*>]*>([\s\S]*?)<\/code><\/pre>/g, '$1') - .replace(//g, '\n') - .replace(/<\/p>/g, '\n') - .replace(/

/g, '') - .replace(/<[^>]+>/g, '') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') + .replace(/]*>]*>([\s\S]*?)<\/code><\/pre>/g, "$1") + .replace(//g, "\n") + .replace(/<\/p>/g, "\n") + .replace(/

/g, "") + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") .replace(/"/g, '"') .replace(/'/g, "'") - .replace(/ /g, ' ') - .replace(/\u00A0/g, ' ') // Non-breaking space + .replace(/ /g, " ") + .replace(/\u00A0/g, " ") // Non-breaking space .trim(); - - + // Remove excessive blank lines - be more aggressive - text = text.replace(/\n\s*\n\s*\n+/g, '\n\n'); // Replace 3+ newlines with 2 - text = text.replace(/\n\s*\n/g, '\n'); // Replace 2 newlines with 1 - + text = text.replace(/\n\s*\n\s*\n+/g, "\n\n"); // Replace 3+ newlines with 2 + text = text.replace(/\n\s*\n/g, "\n"); // Replace 2 newlines with 1 + // For zig error annotations, check if there are multiple platform sections let handled = false; - if (annotation.context.includes('zig error')) { + if (annotation.context.includes("zig error")) { // Split by platform headers within the content const platformSections = text.split(/(?=^\s*[^\s\/]+\.zig\s*-\s*zig error\s+on\s+)/m); - + if (platformSections.length > 1) { // Skip the first empty section if it exists const sections = platformSections.filter(s => s.trim()); - + if (sections.length > 1) { // We have multiple platform errors in one annotation // Extract unique platform names @@ -659,18 +662,20 @@ if (registerRequestIndex !== -1) { platforms.push(platformMatch[1]); } } - + // Show combined header with background color const filename = annotation.context; const fileUrl = fileToUrl(filename); - const platformText = platforms.join(', '); - console.log(`${colors.bgRed}${colors.white}${colors.bold} ${fileUrl} ${colors.reset} ${colors.dim}on ${platformText}${colors.reset}`); + const platformText = platforms.join(", "); + console.log( + `${colors.bgRed}${colors.white}${colors.bold} ${fileUrl} ${colors.reset} ${colors.dim}on ${platformText}${colors.reset}`, + ); console.log(); - + // Show only the first error detail (they're the same) const firstError = sections[0]; - const errorLines = firstError.split('\n'); - + const errorLines = firstError.split("\n"); + // Skip the platform-specific header line and remove excessive blank lines let previousWasBlank = false; for (let i = 0; i < errorLines.length; i++) { @@ -678,14 +683,14 @@ if (registerRequestIndex !== -1) { if (i === 0 && line.match(/\.zig\s*-\s*zig error\s+on\s+/)) { continue; // Skip platform header } - + // Skip multiple consecutive blank lines - const isBlank = line.trim() === ''; + const isBlank = line.trim() === ""; if (isBlank && previousWasBlank) { continue; } previousWasBlank = isBlank; - + console.log(line); // No indentation } console.log(); @@ -693,27 +698,31 @@ if (registerRequestIndex !== -1) { } } } - + // Normal processing for other annotations if (!handled) { // For merged annotations, skip the duplicate headers within the content const isMerged = annotation.merged || (annotation.platformCount && annotation.platformCount > 1); - + // Process lines, removing excessive blank lines let previousWasBlank = false; - text.split('\n').forEach((line, index) => { + text.split("\n").forEach((line, index) => { // For merged annotations, skip duplicate platform headers - if (isMerged && index > 0 && line.match(/^[^\s\/]+\.(ts|js|tsx|jsx|zig)\s*-\s*\d+\s+(failing|errors?|warnings?)\s+on\s+/)) { + if ( + isMerged && + index > 0 && + line.match(/^[^\s\/]+\.(ts|js|tsx|jsx|zig)\s*-\s*\d+\s+(failing|errors?|warnings?)\s+on\s+/) + ) { return; // Skip duplicate headers in merged content } - + // Skip multiple consecutive blank lines - const isBlank = line.trim() === ''; + const isBlank = line.trim() === ""; if (isBlank && previousWasBlank) { return; } previousWasBlank = isBlank; - + console.log(line); // No indentation }); console.log(); @@ -728,4 +737,4 @@ if (registerRequestIndex !== -1) { console.log(`\n${colors.red}${colors.bold}${failedJobs.length} job failures${colors.reset}\n`); console.log("View detailed results at:"); console.log(` https://buildkite.com/bun/bun/builds/${buildNumber}#annotations`); -} \ No newline at end of file +} diff --git a/src/bun.js/api/ResumableSink.classes.ts b/src/bun.js/api/ResumableSink.classes.ts new file mode 100644 index 0000000000..44e4461761 --- /dev/null +++ b/src/bun.js/api/ResumableSink.classes.ts @@ -0,0 +1,33 @@ +import { define } from "../../codegen/class-definitions"; + +function generate(name) { + return define({ + name: name, + construct: true, + finalize: true, + configurable: false, + klass: {}, + JSType: "0b11101110", + proto: { + start: { + fn: "jsStart", + length: 1, + }, + write: { + fn: "jsWrite", + length: 1, + }, + end: { + fn: "jsEnd", + length: 1, + }, + setHandlers: { + fn: "jsSetHandlers", + length: 2, + passThis: true, + }, + }, + values: ["ondrain", "oncancel", "stream"], + }); +} +export default [generate("ResumableFetchSink"), generate("ResumableS3UploadSink")]; diff --git a/src/bun.js/api/bun/socket.zig b/src/bun.js/api/bun/socket.zig index d885173533..d6ce7ac303 100644 --- a/src/bun.js/api/bun/socket.zig +++ b/src/bun.js/api/bun/socket.zig @@ -873,7 +873,7 @@ pub fn NewSocket(comptime ssl: bool) type { if (comptime ssl) { // TLS wrapped but in TCP mode if (this.wrapped == .tcp) { - const res = this.socket.rawWrite(buffer, false); + const res = this.socket.rawWrite(buffer); const uwrote: usize = @intCast(@max(res, 0)); this.bytes_written += uwrote; log("write({d}) = {d}", .{ buffer.len, res }); @@ -881,7 +881,7 @@ pub fn NewSocket(comptime ssl: bool) type { } } - const res = this.socket.write(buffer, false); + const res = this.socket.write(buffer); const uwrote: usize = @intCast(@max(res, 0)); this.bytes_written += uwrote; log("write({d}) = {d}", .{ buffer.len, res }); @@ -1202,7 +1202,7 @@ pub fn NewSocket(comptime ssl: bool) type { fn internalFlush(this: *This) void { if (this.buffered_data_for_node_net.len > 0) { - const written: usize = @intCast(@max(this.socket.write(this.buffered_data_for_node_net.slice(), false), 0)); + const written: usize = @intCast(@max(this.socket.write(this.buffered_data_for_node_net.slice()), 0)); this.bytes_written += written; if (written > 0) { if (this.buffered_data_for_node_net.len > written) { diff --git a/src/bun.js/api/filesystem_router.zig b/src/bun.js/api/filesystem_router.zig index 05521e5283..eafd80a9c5 100644 --- a/src/bun.js/api/filesystem_router.zig +++ b/src/bun.js/api/filesystem_router.zig @@ -14,7 +14,7 @@ const JSGlobalObject = JSC.JSGlobalObject; const strings = bun.strings; const Request = WebCore.Request; const Environment = bun.Environment; -const URLPath = @import("../../http/url_path.zig"); +const URLPath = @import("../../http/URLPath.zig"); const URL = @import("../../url.zig").URL; const Log = bun.logger; const Resolver = @import("../../resolver/resolver.zig").Resolver; diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index 16d33b7897..171963f981 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -3583,6 +3583,34 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionCheckBufferRead, (JSC::JSGlobalObject * globa } return JSValue::encode(jsUndefined()); } +extern "C" EncodedJSValue Bun__assignStreamIntoResumableSink(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue stream, JSC::EncodedJSValue sink) +{ + Zig::GlobalObject* globalThis = reinterpret_cast(globalObject); + return globalThis->assignStreamToResumableSink(JSValue::decode(stream), JSValue::decode(sink)); +} +EncodedJSValue GlobalObject::assignStreamToResumableSink(JSValue stream, JSValue sink) +{ + auto& vm = this->vm(); + JSC::JSFunction* function = this->m_assignStreamToResumableSink.get(); + if (!function) { + function = JSFunction::create(vm, this, static_cast(readableStreamInternalsAssignStreamIntoResumableSinkCodeGenerator(vm)), this); + this->m_assignStreamToResumableSink.set(vm, this, function); + } + + auto callData = JSC::getCallData(function); + JSC::MarkedArgumentBuffer arguments; + arguments.append(stream); + arguments.append(sink); + + WTF::NakedPtr returnedException = nullptr; + + auto result = JSC::profiledCall(this, ProfilingReason::API, function, callData, JSC::jsUndefined(), arguments, returnedException); + if (auto* exception = returnedException.get()) { + return JSC::JSValue::encode(exception); + } + + return JSC::JSValue::encode(result); +} EncodedJSValue GlobalObject::assignToStream(JSValue stream, JSValue controller) { @@ -4411,14 +4439,6 @@ GlobalObject::PromiseFunctions GlobalObject::promiseHandlerID(Zig::FFIFunction h return GlobalObject::PromiseFunctions::Bun__NodeHTTPRequest__onResolve; } else if (handler == Bun__NodeHTTPRequest__onReject) { return GlobalObject::PromiseFunctions::Bun__NodeHTTPRequest__onReject; - } else if (handler == Bun__FetchTasklet__onResolveRequestStream) { - return GlobalObject::PromiseFunctions::Bun__FetchTasklet__onResolveRequestStream; - } else if (handler == Bun__FetchTasklet__onRejectRequestStream) { - return GlobalObject::PromiseFunctions::Bun__FetchTasklet__onRejectRequestStream; - } else if (handler == Bun__S3UploadStream__onResolveRequestStream) { - return GlobalObject::PromiseFunctions::Bun__S3UploadStream__onResolveRequestStream; - } else if (handler == Bun__S3UploadStream__onRejectRequestStream) { - return GlobalObject::PromiseFunctions::Bun__S3UploadStream__onRejectRequestStream; } else if (handler == Bun__FileStreamWrapper__onResolveRequestStream) { return GlobalObject::PromiseFunctions::Bun__FileStreamWrapper__onResolveRequestStream; } else if (handler == Bun__FileStreamWrapper__onRejectRequestStream) { diff --git a/src/bun.js/bindings/ZigGlobalObject.h b/src/bun.js/bindings/ZigGlobalObject.h index dfed8b13d1..d327a65aa5 100644 --- a/src/bun.js/bindings/ZigGlobalObject.h +++ b/src/bun.js/bindings/ZigGlobalObject.h @@ -336,7 +336,7 @@ public: JSObject* subtleCrypto() { return m_subtleCryptoObject.getInitializedOnMainThread(this); } JSC::EncodedJSValue assignToStream(JSValue stream, JSValue controller); - + JSC::EncodedJSValue assignStreamToResumableSink(JSValue stream, JSValue sink); WebCore::EventTarget& eventTarget(); WebCore::ScriptExecutionContext* m_scriptExecutionContext; @@ -373,10 +373,6 @@ public: Bun__onRejectEntryPointResult, Bun__NodeHTTPRequest__onResolve, Bun__NodeHTTPRequest__onReject, - Bun__FetchTasklet__onRejectRequestStream, - Bun__FetchTasklet__onResolveRequestStream, - Bun__S3UploadStream__onRejectRequestStream, - Bun__S3UploadStream__onResolveRequestStream, Bun__FileStreamWrapper__onRejectRequestStream, Bun__FileStreamWrapper__onResolveRequestStream, Bun__FileSink__onResolveStream, @@ -451,6 +447,7 @@ public: #define FOR_EACH_GLOBALOBJECT_GC_MEMBER(V) \ /* TODO: these should use LazyProperty */ \ V(private, WriteBarrier, m_assignToStream) \ + V(private, WriteBarrier, m_assignStreamToResumableSink) \ V(public, WriteBarrier, m_readableStreamToArrayBuffer) \ V(public, WriteBarrier, m_readableStreamToBytes) \ V(public, WriteBarrier, m_readableStreamToBlob) \ diff --git a/src/bun.js/bindings/generated_classes_list.zig b/src/bun.js/bindings/generated_classes_list.zig index 2d2639aaab..904da43346 100644 --- a/src/bun.js/bindings/generated_classes_list.zig +++ b/src/bun.js/bindings/generated_classes_list.zig @@ -83,6 +83,8 @@ pub const Classes = struct { pub const DNSResolver = api.DNS.DNSResolver; pub const S3Client = webcore.S3Client; pub const S3Stat = webcore.S3Stat; + pub const ResumableFetchSink = webcore.ResumableFetchSink; + pub const ResumableS3UploadSink = webcore.ResumableS3UploadSink; pub const HTMLBundle = api.HTMLBundle; pub const RedisClient = api.Valkey; pub const BlockList = api.BlockList; diff --git a/src/bun.js/bindings/headers.h b/src/bun.js/bindings/headers.h index 4411cb78c5..1f93ac5e7f 100644 --- a/src/bun.js/bindings/headers.h +++ b/src/bun.js/bindings/headers.h @@ -754,11 +754,6 @@ CPP_DECL bool JSC__CustomGetterSetter__isSetterNull(JSC::CustomGetterSetter *arg BUN_DECLARE_HOST_FUNCTION(Bun__onResolveEntryPointResult); BUN_DECLARE_HOST_FUNCTION(Bun__onRejectEntryPointResult); -BUN_DECLARE_HOST_FUNCTION(Bun__FetchTasklet__onResolveRequestStream); -BUN_DECLARE_HOST_FUNCTION(Bun__FetchTasklet__onRejectRequestStream); - -BUN_DECLARE_HOST_FUNCTION(Bun__S3UploadStream__onResolveRequestStream); -BUN_DECLARE_HOST_FUNCTION(Bun__S3UploadStream__onRejectRequestStream); BUN_DECLARE_HOST_FUNCTION(Bun__FileStreamWrapper__onResolveRequestStream); BUN_DECLARE_HOST_FUNCTION(Bun__FileStreamWrapper__onRejectRequestStream); diff --git a/src/bun.js/ipc.zig b/src/bun.js/ipc.zig index f3ae7a053d..27bcd1789d 100644 --- a/src/bun.js/ipc.zig +++ b/src/bun.js/ipc.zig @@ -840,7 +840,7 @@ pub const SendQueue = struct { if (fd) |fd_unwrapped| { this._onWriteComplete(socket.writeFd(data, fd_unwrapped)); } else { - this._onWriteComplete(socket.write(data, false)); + this._onWriteComplete(socket.write(data)); } }, }; diff --git a/src/bun.js/webcore.zig b/src/bun.js/webcore.zig index 930957ef5a..75bc187633 100644 --- a/src/bun.js/webcore.zig +++ b/src/bun.js/webcore.zig @@ -26,6 +26,8 @@ pub const encoding = @import("webcore/encoding.zig"); pub const ReadableStream = @import("webcore/ReadableStream.zig"); pub const Blob = @import("webcore/Blob.zig"); pub const S3Stat = @import("webcore/S3Stat.zig").S3Stat; +pub const ResumableFetchSink = @import("webcore/ResumableSink.zig").ResumableFetchSink; +pub const ResumableS3UploadSink = @import("webcore/ResumableSink.zig").ResumableS3UploadSink; pub const S3Client = @import("webcore/S3Client.zig").S3Client; pub const Request = @import("webcore/Request.zig"); pub const Body = @import("webcore/Body.zig"); @@ -69,6 +71,10 @@ pub const Pipe = struct { ctx: ?*anyopaque = null, onPipe: ?Function = null, + pub inline fn isEmpty(this: *const Pipe) bool { + return this.ctx == null and this.onPipe == null; + } + pub const Function = *const fn ( ctx: *anyopaque, stream: streams.Result, diff --git a/src/bun.js/webcore/Request.zig b/src/bun.js/webcore/Request.zig index 4728da2b98..efcd5ab1eb 100644 --- a/src/bun.js/webcore/Request.zig +++ b/src/bun.js/webcore/Request.zig @@ -941,7 +941,7 @@ const bun = @import("bun"); const MimeType = bun.http.MimeType; const JSC = bun.JSC; -const Method = @import("../../http/method.zig").Method; +const Method = @import("../../http/Method.zig").Method; const FetchHeaders = bun.webcore.FetchHeaders; const AbortSignal = JSC.WebCore.AbortSignal; const Output = bun.Output; diff --git a/src/bun.js/webcore/Response.zig b/src/bun.js/webcore/Response.zig index bdd0552cd6..4e4f5f5bec 100644 --- a/src/bun.js/webcore/Response.zig +++ b/src/bun.js/webcore/Response.zig @@ -727,7 +727,7 @@ const MimeType = bun.http.MimeType; const http = bun.http; const JSC = bun.JSC; -const Method = @import("../../http/method.zig").Method; +const Method = @import("../../http/Method.zig").Method; const FetchHeaders = bun.webcore.FetchHeaders; const Output = bun.Output; const string = bun.string; diff --git a/src/bun.js/webcore/ResumableSink.zig b/src/bun.js/webcore/ResumableSink.zig new file mode 100644 index 0000000000..27dc0b33a2 --- /dev/null +++ b/src/bun.js/webcore/ResumableSink.zig @@ -0,0 +1,362 @@ +/// ResumableSink allows a simplified way of reading a stream into a native Writable Interface, allowing to pause and resume the stream without the use of promises. +/// returning false on `onWrite` will pause the stream and calling .drain() will resume the stream consumption. +/// onEnd is always called when the stream is done or errored. +/// Calling `cancel` will cancel the stream, onEnd will be called with the reason passed to cancel. +/// Different from JSSink this is not intended to be exposed to the users, like FileSink or HTTPRequestSink etc. +pub fn ResumableSink( + comptime js: type, + comptime Context: type, + comptime onWrite: fn (context: *Context, chunk: []const u8) bool, + comptime onEnd: fn (context: *Context, err: ?JSC.JSValue) void, +) type { + return struct { + const log = bun.Output.scoped(.ResumableSink, false); + pub const toJS = js.toJS; + pub const fromJS = js.fromJS; + pub const fromJSDirect = js.fromJSDirect; + + pub const new = bun.TrivialNew(@This()); + const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{}); + pub const ref = RefCount.ref; + pub const deref = RefCount.deref; + const setCancel = js.oncancelSetCached; + const getCancel = js.oncancelGetCached; + const setDrain = js.ondrainSetCached; + const getDrain = js.ondrainGetCached; + const setStream = js.streamSetCached; + const getStream = js.streamGetCached; + ref_count: RefCount, + self: JSC.Strong.Optional = JSC.Strong.Optional.empty, + // We can have a detached self, and still have a strong reference to the stream + stream: JSC.WebCore.ReadableStream.Strong = .{}, + globalThis: *JSC.JSGlobalObject, + context: *Context, + highWaterMark: i64 = 16384, + status: Status = .started, + + const Status = enum(u8) { + started, + piped, + paused, + done, + }; + + pub fn constructor(globalThis: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError!*@This() { + return globalThis.throwInvalidArguments("ResumableSink is not constructable", .{}); + } + + pub fn init(globalThis: *JSC.JSGlobalObject, stream: JSC.WebCore.ReadableStream, context: *Context) *@This() { + return initExactRefs(globalThis, stream, context, 1); + } + + pub fn initExactRefs(globalThis: *JSC.JSGlobalObject, stream: JSC.WebCore.ReadableStream, context: *Context, ref_count: u32) *@This() { + const this = @This().new(.{ + .globalThis = globalThis, + .context = context, + .ref_count = RefCount.initExactRefs(ref_count), + }); + if (stream.isLocked(globalThis) or stream.isDisturbed(globalThis)) { + var err = JSC.SystemError{ + .code = bun.String.static(@tagName(JSC.Node.ErrorCode.ERR_STREAM_CANNOT_PIPE)), + .message = bun.String.static("Stream already used, please create a new one"), + }; + const err_instance = err.toErrorInstance(globalThis); + err_instance.ensureStillAlive(); + this.status = .done; + onEnd(this.context, err_instance); + this.deref(); + return this; + } + if (stream.ptr == .Bytes) { + const byte_stream: *bun.webcore.ByteStream = stream.ptr.Bytes; + // if pipe is empty, we can pipe + if (byte_stream.pipe.isEmpty()) { + // equivalent to onStart to get the highWaterMark + this.highWaterMark = if (byte_stream.highWaterMark < std.math.maxInt(i64)) + @intCast(byte_stream.highWaterMark) + else + std.math.maxInt(i64); + + if (byte_stream.has_received_last_chunk) { + this.status = .done; + const err = brk_err: { + const pending = byte_stream.pending.result; + if (pending == .err) { + const js_err, const was_strong = pending.err.toJSWeak(this.globalThis); + js_err.ensureStillAlive(); + if (was_strong == .Strong) + js_err.unprotect(); + break :brk_err js_err; + } + break :brk_err null; + }; + + const bytes = byte_stream.drain().listManaged(bun.default_allocator); + defer bytes.deinit(); + log("onWrite {}", .{bytes.items.len}); + _ = onWrite(this.context, bytes.items); + onEnd(this.context, err); + this.deref(); + return this; + } + // We can pipe but we also wanna to drain as much as possible first + const bytes = byte_stream.drain().listManaged(bun.default_allocator); + defer bytes.deinit(); + // lets write and see if we can still pipe or if we have backpressure + if (bytes.items.len > 0) { + log("onWrite {}", .{bytes.items.len}); + // we ignore the return value here because we dont want to pause the stream + // if we pause will just buffer in the pipe and we can do the buffer in one place + _ = onWrite(this.context, bytes.items); + } + this.status = .piped; + byte_stream.pipe = JSC.WebCore.Pipe.Wrap(@This(), onStreamPipe).init(this); + this.ref(); // one ref for the pipe + + // we only need the stream, we dont need to touch JS side yet + this.stream = JSC.WebCore.ReadableStream.Strong.init(stream, this.globalThis); + return this; + } + } + // lets go JS side route + const self = this.toJS(globalThis); + self.ensureStillAlive(); + const js_stream = stream.toJS(); + js_stream.ensureStillAlive(); + _ = Bun__assignStreamIntoResumableSink(globalThis, js_stream, self); + this.self = JSC.Strong.Optional.create(self, globalThis); + setStream(self, globalThis, js_stream); + return this; + } + + pub fn jsSetHandlers(_: *@This(), globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame, this_value: JSC.JSValue) bun.JSError!JSC.JSValue { + JSC.markBinding(@src()); + const args = callframe.arguments(); + + if (args.len < 2) { + return globalThis.throwInvalidArguments("ResumableSink.setHandlers requires at least 2 arguments", .{}); + } + + const ondrain = args.ptr[0]; + const oncancel = args.ptr[1]; + + if (ondrain.isCallable()) { + setDrain(this_value, globalThis, ondrain); + } + if (oncancel.isCallable()) { + setCancel(this_value, globalThis, oncancel); + } + return .js_undefined; + } + + pub fn jsStart(this: *@This(), globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { + JSC.markBinding(@src()); + const args = callframe.arguments(); + if (args.len > 0 and args[0].isObject()) { + if (try args[0].getOptionalInt(globalThis, "highWaterMark", i64)) |highWaterMark| { + this.highWaterMark = highWaterMark; + } + } + + return .js_undefined; + } + + pub fn jsWrite(this: *@This(), globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { + JSC.markBinding(@src()); + const args = callframe.arguments(); + // ignore any call if detached + if (!this.self.has() or this.status == .done) return .js_undefined; + + if (args.len < 1) { + return globalThis.throwInvalidArguments("ResumableSink.write requires at least 1 argument", .{}); + } + + const buffer = args[0]; + buffer.ensureStillAlive(); + if (try JSC.Node.StringOrBuffer.fromJS(globalThis, bun.default_allocator, buffer)) |sb| { + defer sb.deinit(); + const bytes = sb.slice(); + log("jsWrite {}", .{bytes.len}); + const should_continue = onWrite(this.context, bytes); + if (!should_continue) { + log("paused", .{}); + this.status = .paused; + } + return JSC.jsBoolean(should_continue); + } + + return globalThis.throwInvalidArguments("ResumableSink.write requires a string or buffer", .{}); + } + + pub fn jsEnd(this: *@This(), _: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { + JSC.markBinding(@src()); + const args = callframe.arguments(); + // ignore any call if detached + if (!this.self.has() or this.status == .done) return .js_undefined; + this.detachJS(); + log("jsEnd {}", .{args.len}); + this.status = .done; + + onEnd(this.context, if (args.len > 0) args[0] else null); + return .js_undefined; + } + + pub fn drain(this: *@This()) void { + log("drain", .{}); + if (this.status != .paused) { + return; + } + if (this.self.get()) |js_this| { + const globalObject = this.globalThis; + const vm = globalObject.bunVM(); + vm.eventLoop().enter(); + defer vm.eventLoop().exit(); + if (getDrain(js_this)) |ondrain| { + if (ondrain.isCallable()) { + this.status = .started; + _ = ondrain.call(globalObject, .js_undefined, &.{.js_undefined}) catch |err| { + // should never happen + bun.debugAssert(false); + _ = globalObject.takeError(err); + }; + } + } + } + } + + pub fn cancel(this: *@This(), reason: JSC.JSValue) void { + if (this.status == .piped) { + reason.ensureStillAlive(); + this.endPipe(reason); + return; + } + if (this.self.get()) |js_this| { + this.status = .done; + js_this.ensureStillAlive(); + + const globalObject = this.globalThis; + const vm = globalObject.bunVM(); + vm.eventLoop().enter(); + defer vm.eventLoop().exit(); + + if (getCancel(js_this)) |oncancel| { + oncancel.ensureStillAlive(); + // detach first so if cancel calls end will be a no-op + this.detachJS(); + // call onEnd to indicate the native side that the stream errored + onEnd(this.context, reason); + if (oncancel.isCallable()) { + _ = oncancel.call(globalObject, .js_undefined, &.{ .js_undefined, reason }) catch |err| { + // should never happen + bun.debugAssert(false); + _ = globalObject.takeError(err); + }; + } + } else { + // should never happen but lets call onEnd to indicate the native side that the stream errored + this.detachJS(); + onEnd(this.context, reason); + } + } + } + + fn detachJS(this: *@This()) void { + if (this.self.trySwap()) |js_this| { + setDrain(js_this, this.globalThis, .zero); + setCancel(js_this, this.globalThis, .zero); + setStream(js_this, this.globalThis, .zero); + this.self.deinit(); + this.self = JSC.Strong.Optional.empty; + } + } + pub fn deinit(this: *@This()) void { + this.detachJS(); + this.stream.deinit(); + bun.destroy(this); + } + + pub fn finalize(this: *@This()) void { + this.deref(); + } + + fn onStreamPipe( + this: *@This(), + stream: bun.webcore.streams.Result, + allocator: std.mem.Allocator, + ) void { + const stream_needs_deinit = stream == .owned or stream == .owned_and_done; + + defer { + if (stream_needs_deinit) { + if (stream == .owned_and_done) { + stream.owned_and_done.listManaged(allocator).deinit(); + } else { + stream.owned.listManaged(allocator).deinit(); + } + } + } + const chunk = stream.slice(); + log("onWrite {}", .{chunk.len}); + const stopStream = !onWrite(this.context, chunk); + const is_done = stream.isDone(); + + if (is_done) { + const err: ?JSC.JSValue = brk_err: { + if (stream == .err) { + const js_err, const was_strong = stream.err.toJSWeak(this.globalThis); + js_err.ensureStillAlive(); + if (was_strong == .Strong) + js_err.unprotect(); + break :brk_err js_err; + } + break :brk_err null; + }; + this.endPipe(err); + } else if (stopStream) { + // dont make sense pausing the stream here + // it will be buffered in the pipe anyways + } + } + + fn endPipe(this: *@This(), err: ?JSC.JSValue) void { + log("endPipe", .{}); + if (this.status != .piped) return; + this.status = .done; + if (this.stream.get(this.globalThis)) |stream_| { + if (stream_.ptr == .Bytes) { + stream_.ptr.Bytes.pipe = .{}; + } + if (err != null) { + stream_.cancel(this.globalThis); + } else { + stream_.done(this.globalThis); + } + var stream = this.stream; + this.stream = .{}; + stream.deinit(); + } + // We ref when we attach the stream so we deref when we detach the stream + this.deref(); + + onEnd(this.context, err); + if (this.self.has()) { + // JS owns the stream, so we need to detach the JS and let finalize handle the deref + // this should not happen but lets handle it anyways + this.detachJS(); + } else { + // no js attached, so we can just deref + this.deref(); + } + } + }; +} + +pub const ResumableFetchSink = ResumableSink(JSC.Codegen.JSResumableFetchSink, FetchTasklet, FetchTasklet.writeRequestData, FetchTasklet.writeEndRequest); +const S3UploadStreamWrapper = @import("../../s3/client.zig").S3UploadStreamWrapper; +pub const ResumableS3UploadSink = ResumableSink(JSC.Codegen.JSResumableS3UploadSink, S3UploadStreamWrapper, S3UploadStreamWrapper.writeRequestData, S3UploadStreamWrapper.writeEndRequest); +const std = @import("std"); +const bun = @import("bun"); +const FetchTasklet = @import("./fetch.zig").FetchTasklet; + +const JSC = bun.JSC; +extern fn Bun__assignStreamIntoResumableSink(globalThis: *JSC.JSGlobalObject, stream: JSC.JSValue, sink: JSC.JSValue) JSC.JSValue; diff --git a/src/bun.js/webcore/fetch.zig b/src/bun.js/webcore/fetch.zig index 2c279aec12..deb177df2a 100644 --- a/src/bun.js/webcore/fetch.zig +++ b/src/bun.js/webcore/fetch.zig @@ -61,16 +61,17 @@ pub const fetch_type_error_strings: JSTypeErrorEnum = brk: { }; pub const FetchTasklet = struct { - pub const FetchTaskletStream = JSC.WebCore.NetworkSink; + pub const ResumableSink = JSC.WebCore.ResumableFetchSink; const log = Output.scoped(.FetchTasklet, false); - sink: ?*FetchTaskletStream.JSSink = null, + sink: ?*ResumableSink = null, http: ?*http.AsyncHTTP = null, result: http.HTTPClientResult = .{}, metadata: ?http.HTTPResponseMetadata = null, javascript_vm: *VirtualMachine = undefined, global_this: *JSGlobalObject = undefined, request_body: HTTPRequestBody = undefined, + request_body_streaming_buffer: ?*http.ThreadSafeStreamBuffer = null, /// buffer being used by AsyncHTTP response_buffer: MutableString = undefined, @@ -148,7 +149,7 @@ pub const FetchTasklet = struct { pub const HTTPRequestBody = union(enum) { AnyBlob: AnyBlob, - Sendfile: http.Sendfile, + Sendfile: http.SendFile, ReadableStream: JSC.WebCore.ReadableStream.Strong, pub const Empty: HTTPRequestBody = .{ .AnyBlob = .{ .Blob = .{} } }; @@ -242,19 +243,19 @@ pub const FetchTasklet = struct { } fn clearSink(this: *FetchTasklet) void { - if (this.sink) |wrapper| { + if (this.sink) |sink| { this.sink = null; - - wrapper.sink.done = true; - wrapper.sink.ended = true; - wrapper.sink.finalize(); - wrapper.detach(); - wrapper.sink.finalizeAndDestroy(); + sink.deref(); + } + if (this.request_body_streaming_buffer) |buffer| { + this.request_body_streaming_buffer = null; + buffer.clearDrainCallback(); + buffer.deref(); } } fn clearData(this: *FetchTasklet) void { - log("clearData", .{}); + log("clearData ", .{}); const allocator = this.memory_reporter.allocator(); if (this.url_proxy_buffer.len > 0) { allocator.free(this.url_proxy_buffer); @@ -339,136 +340,18 @@ pub const FetchTasklet = struct { return null; } - pub fn onResolveRequestStream(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { - var args = callframe.arguments_old(2); - var this: *@This() = args.ptr[args.len - 1].asPromisePtr(@This()); - defer this.deref(); - if (this.request_body == .ReadableStream) { - var readable_stream_ref = this.request_body.ReadableStream; - this.request_body.ReadableStream = .{}; - defer readable_stream_ref.deinit(); - if (readable_stream_ref.get(globalThis)) |stream| { - stream.done(globalThis); - this.clearSink(); - } - } - - return .js_undefined; - } - - pub fn onRejectRequestStream(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { - const args = callframe.arguments_old(2); - var this = args.ptr[args.len - 1].asPromisePtr(@This()); - defer this.deref(); - const err = args.ptr[0]; - if (this.request_body == .ReadableStream) { - var readable_stream_ref = this.request_body.ReadableStream; - this.request_body.ReadableStream = .{}; - defer readable_stream_ref.deinit(); - if (readable_stream_ref.get(globalThis)) |stream| { - stream.cancel(globalThis); - this.clearSink(); - } - } - - this.abortListener(err); - return .js_undefined; - } - comptime { - const jsonResolveRequestStream = JSC.toJSHostFn(onResolveRequestStream); - @export(&jsonResolveRequestStream, .{ .name = "Bun__FetchTasklet__onResolveRequestStream" }); - const jsonRejectRequestStream = JSC.toJSHostFn(onRejectRequestStream); - @export(&jsonRejectRequestStream, .{ .name = "Bun__FetchTasklet__onRejectRequestStream" }); - } - pub fn startRequestStream(this: *FetchTasklet) void { this.is_waiting_request_stream_start = false; bun.assert(this.request_body == .ReadableStream); if (this.request_body.ReadableStream.get(this.global_this)) |stream| { - this.ref(); // lets only unref when sink is done - const globalThis = this.global_this; - var response_stream = FetchTaskletStream.new(.{ - .task = .{ .fetch = this }, - .buffer = .{}, - .globalThis = globalThis, - }).toSink(); - var signal = &response_stream.sink.signal; - this.sink = response_stream; - - signal.* = FetchTaskletStream.JSSink.SinkSignal.init(JSValue.zero); - - // explicitly set it to a dead pointer - // we use this memory address to disable signals being sent - signal.clear(); - bun.assert(signal.isDead()); - - // We are already corked! - const assignment_result: JSValue = FetchTaskletStream.JSSink.assignToStream( - globalThis, - stream.value, - response_stream, - @as(**anyopaque, @ptrCast(&signal.ptr)), - ); - - assignment_result.ensureStillAlive(); - - // assert that it was updated - bun.assert(!signal.isDead()); - - if (assignment_result.toError()) |err_value| { - response_stream.detach(); - this.sink = null; - response_stream.sink.finalizeAndDestroy(); - return this.abortListener(err_value); - } - - if (!assignment_result.isEmptyOrUndefinedOrNull()) { - assignment_result.ensureStillAlive(); - // it returns a Promise when it goes through ReadableStreamDefaultReader - if (assignment_result.asAnyPromise()) |promise| { - switch (promise.status(globalThis.vm())) { - .pending => { - this.ref(); - assignment_result.then( - globalThis, - this, - onResolveRequestStream, - onRejectRequestStream, - ); - }, - .fulfilled => { - var readable_stream_ref = this.request_body.ReadableStream; - this.request_body.ReadableStream = .{}; - defer { - stream.done(globalThis); - this.clearSink(); - readable_stream_ref.deinit(); - } - }, - .rejected => { - var readable_stream_ref = this.request_body.ReadableStream; - this.request_body.ReadableStream = .{}; - defer { - stream.cancel(globalThis); - this.clearSink(); - readable_stream_ref.deinit(); - } - - this.abortListener(promise.result(globalThis.vm())); - }, - } - return; - } else { - // if is not a promise we treat it as Error - response_stream.detach(); - this.sink = null; - response_stream.sink.finalizeAndDestroy(); - return this.abortListener(assignment_result); - } - } + this.ref(); // lets only unref when sink is done + // +1 because the task refs the sink + const sink = ResumableSink.initExactRefs(globalThis, stream, this, 2); + this.sink = sink; } } + pub fn onBodyReceived(this: *FetchTasklet) void { const success = this.result.isSuccess(); const globalThis = this.global_this; @@ -484,17 +367,28 @@ pub const FetchTasklet = struct { var err = this.onReject(); var need_deinit = true; defer if (need_deinit) err.deinit(); + var js_err = JSValue.zero; // if we are streaming update with error if (this.readable_stream_ref.get(globalThis)) |readable| { if (readable.ptr == .Bytes) { + js_err = err.toJS(globalThis); + js_err.ensureStillAlive(); readable.ptr.Bytes.onData( .{ - .err = .{ .JSValue = err.toJS(globalThis) }, + .err = .{ .JSValue = js_err }, }, bun.default_allocator, ); } } + if (this.sink) |sink| { + if (js_err == .zero) { + js_err = err.toJS(globalThis); + js_err.ensureStillAlive(); + } + sink.cancel(js_err); + return; + } // if we are buffering resolve the promise if (this.getCurrentResponse()) |response| { response.body.value.toErrorInstance(err, globalThis); @@ -710,7 +604,10 @@ pub const FetchTasklet = struct { false => brk: { // in this case we wanna a JSC.Strong.Optional so we just convert it var value = this.onReject(); - _ = value.toJS(globalThis); + const err = value.toJS(globalThis); + if (this.sink) |sink| { + sink.cancel(err); + } break :brk value.JSValue; }, }; @@ -1232,9 +1129,12 @@ pub const FetchTasklet = struct { fetch_tasklet.http.?.client.flags.is_streaming_request_body = isStream; fetch_tasklet.is_waiting_request_stream_start = isStream; if (isStream) { + const buffer = http.ThreadSafeStreamBuffer.new(.{}); + buffer.setDrainCallback(FetchTasklet, FetchTasklet.onWriteRequestDataDrain, fetch_tasklet); + fetch_tasklet.request_body_streaming_buffer = buffer; fetch_tasklet.http.?.request_body = .{ .stream = .{ - .buffer = .{}, + .buffer = buffer, .ended = false, }, }; @@ -1266,17 +1166,74 @@ pub const FetchTasklet = struct { reason.ensureStillAlive(); this.abort_reason.set(this.global_this, reason); this.abortTask(); - if (this.sink) |wrapper| { - wrapper.sink.abort(); + if (this.sink) |sink| { + sink.cancel(reason); return; } } - pub fn sendRequestData(this: *FetchTasklet, data: []const u8, ended: bool) void { - if (this.http) |http_| { - http.http_thread.scheduleRequestWrite(http_, data, ended); - } else if (data.len != 3) { - bun.default_allocator.free(data); + /// This is ALWAYS called from the http thread and we cannot touch the buffer here because is locked + pub fn onWriteRequestDataDrain(this: *FetchTasklet) void { + // ref until the main thread callback is called + this.ref(); + this.javascript_vm.eventLoop().enqueueTaskConcurrent(JSC.ConcurrentTask.fromCallback(this, FetchTasklet.resumeRequestDataStream)); + } + + /// This is ALWAYS called from the main thread + pub fn resumeRequestDataStream(this: *FetchTasklet) void { + // deref when done because we ref inside onWriteRequestDataDrain + defer this.deref(); + if (this.sink) |sink| { + sink.drain(); + } + } + + pub fn writeRequestData(this: *FetchTasklet, data: []const u8) bool { + log("writeRequestData {}", .{data.len}); + if (this.request_body_streaming_buffer) |buffer| { + const highWaterMark = if (this.sink) |sink| sink.highWaterMark else 16384; + const stream_buffer = buffer.acquire(); + var needs_schedule = false; + defer if (needs_schedule) { + // wakeup the http thread to write the data + http.http_thread.scheduleRequestWrite(this.http.?, .data); + }; + defer buffer.release(); + + // dont have backpressure so we will schedule the data to be written + // if we have backpressure the onWritable will drain the buffer + needs_schedule = stream_buffer.isEmpty(); + //16 is the max size of a hex number size that represents 64 bits + 2 for the \r\n + var formated_size_buffer: [18]u8 = undefined; + const formated_size = std.fmt.bufPrint(formated_size_buffer[0..], "{x}\r\n", .{data.len}) catch bun.outOfMemory(); + stream_buffer.ensureUnusedCapacity(formated_size.len + data.len + 2) catch bun.outOfMemory(); + stream_buffer.writeAssumeCapacity(formated_size); + stream_buffer.writeAssumeCapacity(data); + stream_buffer.writeAssumeCapacity("\r\n"); + + // pause the stream if we hit the high water mark + return stream_buffer.size() >= highWaterMark; + } + return false; + } + + pub fn writeEndRequest(this: *FetchTasklet, err: ?JSC.JSValue) void { + log("writeEndRequest hasError? {}", .{err != null}); + this.clearSink(); + defer this.deref(); + if (err) |jsError| { + if (this.signal_store.aborted.load(.monotonic) or this.abort_reason.has()) { + return; + } + if (!jsError.isUndefinedOrNull()) { + this.abort_reason.set(this.global_this, jsError); + } + this.abortTask(); + } else { + if (this.http) |http_| { + // just tell to write the end of the chunked encoding aka 0\r\n\r\n + http.http_thread.scheduleRequestWrite(http_, .endChunked); + } } } @@ -2413,7 +2370,7 @@ pub fn Bun__fetch_( .result => |fd| fd, }; - if (proxy == null and bun.http.Sendfile.isEligible(url)) { + if (proxy == null and bun.http.SendFile.isEligible(url)) { use_sendfile: { const stat: bun.Stat = switch (bun.sys.fstat(opened_fd)) { .result => |result| result, @@ -2748,7 +2705,7 @@ const Blob = JSC.WebCore.Blob; const Response = JSC.WebCore.Response; const Request = JSC.WebCore.Request; const Headers = bun.http.Headers; -const Method = @import("../../http/method.zig").Method; +const Method = @import("../../http/Method.zig").Method; const Body = JSC.WebCore.Body; const Async = bun.Async; const SSLConfig = @import("../api/server.zig").ServerConfig.SSLConfig; diff --git a/src/bun.js/webcore/streams.zig b/src/bun.js/webcore/streams.zig index d5e3ed22ab..a500f6dcb5 100644 --- a/src/bun.js/webcore/streams.zig +++ b/src/bun.js/webcore/streams.zig @@ -1327,68 +1327,32 @@ pub const NetworkSink = struct { pub const new = bun.TrivialNew(@This()); pub const deinit = bun.TrivialDeinit(@This()); - task: ?HTTPWritableStream = null, + task: ?*bun.S3.MultiPartUpload = null, signal: Signal = .{}, globalThis: *JSGlobalObject = undefined, highWaterMark: Blob.SizeType = 2048, - buffer: bun.io.StreamBuffer, + flushPromise: JSC.JSPromise.Strong = .{}, + endPromise: JSC.JSPromise.Strong = .{}, ended: bool = false, done: bool = false, cancel: bool = false, - encoded: bool = true, - endPromise: JSC.JSPromise.Strong = .{}, - - auto_flusher: AutoFlusher = AutoFlusher{}, - - const HTTPWritableStream = union(enum) { - fetch: *JSC.WebCore.Fetch.FetchTasklet, - s3_upload: *bun.S3.MultiPartUpload, - }; + const log = bun.Output.scoped(.NetworkSink, false); fn getHighWaterMark(this: *@This()) Blob.SizeType { if (this.task) |task| { - return switch (task) { - .s3_upload => |s3| @truncate(s3.partSizeInBytes()), - else => this.highWaterMark, - }; + return task.partSizeInBytes(); } return this.highWaterMark; } - fn unregisterAutoFlusher(this: *@This()) void { - if (this.auto_flusher.registered) - AutoFlusher.unregisterDeferredMicrotaskWithTypeUnchecked(@This(), this, this.globalThis.bunVM()); - } - - fn registerAutoFlusher(this: *@This()) void { - if (!this.auto_flusher.registered) - AutoFlusher.registerDeferredMicrotaskWithTypeUnchecked(@This(), this, this.globalThis.bunVM()); - } pub fn path(this: *@This()) ?[]const u8 { if (this.task) |task| { - return switch (task) { - .s3_upload => |s3| s3.path, - else => null, - }; + return task.path; } return null; } - pub fn onAutoFlush(this: *@This()) bool { - if (this.done) { - this.auto_flusher.registered = false; - return false; - } - - _ = this.internalFlush() catch 0; - if (this.buffer.isEmpty()) { - this.auto_flusher.registered = false; - return false; - } - return true; - } - pub fn start(this: *@This(), stream_start: Start) JSC.Maybe(void) { if (this.ended) { return .{ .result = {} }; @@ -1417,84 +1381,47 @@ pub const NetworkSink = struct { return @ptrCast(this); } pub fn finalize(this: *@This()) void { - this.unregisterAutoFlusher(); - - var buffer = this.buffer; - this.buffer = .{}; - buffer.deinit(); - this.detachWritable(); } fn detachWritable(this: *@This()) void { if (this.task) |task| { this.task = null; - switch (task) { - inline .fetch, .s3_upload => |writable| { - writable.deref(); - }, - } + task.deref(); } } - fn sendRequestData(writable: HTTPWritableStream, data: []const u8, is_last: bool) void { - switch (writable) { - inline .fetch, .s3_upload => |task| task.sendRequestData(data, is_last), + pub fn onWritable(task: *bun.S3.MultiPartUpload, this: *@This(), flushed: u64) void { + log("onWritable flushed: {d} state: {s}", .{ flushed, @tagName(task.state) }); + if (this.flushPromise.hasValue()) { + this.flushPromise.resolve(this.globalThis, JSC.JSValue.jsNumber(flushed)); } } - pub fn send(this: *@This(), data: []const u8, is_last: bool) !void { - if (this.done) return; - - if (this.task) |task| { - if (is_last) this.done = true; - if (this.encoded) { - if (data.len == 0) { - sendRequestData(task, bun.http.end_of_chunked_http1_1_encoding_response_body, true); - return; - } - - // chunk encoding is really simple - if (is_last) { - const chunk = std.fmt.allocPrint(bun.default_allocator, "{x}\r\n{s}\r\n0\r\n\r\n", .{ data.len, data }) catch return error.OOM; - sendRequestData(task, chunk, true); - } else { - const chunk = std.fmt.allocPrint(bun.default_allocator, "{x}\r\n{s}\r\n", .{ data.len, data }) catch return error.OOM; - sendRequestData(task, chunk, false); - } - } else { - sendRequestData(task, data, is_last); - } - } - } - - pub fn internalFlush(this: *@This()) !usize { - if (this.done) return 0; - var flushed: usize = 0; - // we need to respect the max len for the chunk - while (this.buffer.isNotEmpty()) { - const bytes = this.buffer.slice(); - const len: u32 = @min(bytes.len, std.math.maxInt(u32)); - try this.send(bytes, this.buffer.list.items.len - (this.buffer.cursor + len) == 0 and this.ended); - flushed += len; - this.buffer.cursor = len; - if (this.buffer.isEmpty()) { - this.buffer.reset(); - } - } - if (this.ended and !this.done) { - try this.send("", true); - this.finalize(); - } - return flushed; - } - - pub fn flush(this: *@This()) JSC.Maybe(void) { - _ = this.internalFlush() catch 0; + pub fn flush(_: *@This()) JSC.Maybe(void) { return .{ .result = {} }; } + pub fn flushFromJS(this: *@This(), globalThis: *JSGlobalObject, _: bool) JSC.Maybe(JSValue) { - return .{ .result = JSC.JSPromise.resolvedPromiseValue(globalThis, JSValue.jsNumber(this.internalFlush() catch 0)) }; + // still waiting for more data tobe flushed + if (this.flushPromise.hasValue()) { + return .{ .result = this.flushPromise.value() }; + } + + // nothing todo here + if (this.done) { + return .{ .result = JSC.JSPromise.resolvedPromiseValue(globalThis, JSValue.jsNumber(0)) }; + } + // flush more + if (this.task) |task| { + if (!task.isQueueEmpty()) { + // we have something queued, we need to wait for the next flush + this.flushPromise = JSC.JSPromise.Strong.init(globalThis); + return .{ .result = this.flushPromise.value() }; + } + } + // we are done flushing no backpressure + return .{ .result = JSC.JSPromise.resolvedPromiseValue(globalThis, JSValue.jsNumber(0)) }; } pub fn finalizeAndDestroy(this: *@This()) void { this.finalize(); @@ -1516,28 +1443,11 @@ pub const NetworkSink = struct { const bytes = data.slice(); const len = @as(Blob.SizeType, @truncate(bytes.len)); - if (this.buffer.size() == 0 and len >= this.getHighWaterMark()) { - // fast path: - // - large-ish chunk - this.send(bytes, false) catch { - return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; - }; - return .{ .owned = len }; - } else if (this.buffer.size() + len >= this.getHighWaterMark()) { - _ = this.buffer.write(bytes) catch { - return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; - }; - _ = this.internalFlush() catch { - return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; - }; - return .{ .owned = len }; - } else { - // queue the data wait until highWaterMark is reached or the auto flusher kicks in - this.buffer.write(bytes) catch { + if (this.task) |task| { + _ = task.writeBytes(bytes, false) catch { return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; }; } - this.registerAutoFlusher(); return .{ .owned = len }; } @@ -1550,47 +1460,11 @@ pub const NetworkSink = struct { const bytes = data.slice(); const len = @as(Blob.SizeType, @truncate(bytes.len)); - if (this.buffer.size() == 0 and len >= this.getHighWaterMark()) { - // common case - if (strings.isAllASCII(bytes)) { - // fast path: - // - large-ish chunk - this.send(bytes, false) catch { - return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; - }; - return .{ .owned = len }; - } - - const check_ascii = false; - this.buffer.writeLatin1(bytes, check_ascii) catch { - return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; - }; - - _ = this.internalFlush() catch { - return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; - }; - return .{ .owned = len }; - } else if (this.buffer.size() + len >= this.getHighWaterMark()) { - // kinda fast path: - // - combined chunk is large enough to flush automatically - - const check_ascii = true; - this.buffer.writeLatin1(bytes, check_ascii) catch { - return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; - }; - _ = this.internalFlush() catch { - return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; - }; - return .{ .owned = len }; - } else { - const check_ascii = true; - this.buffer.writeLatin1(bytes, check_ascii) catch { + if (this.task) |task| { + _ = task.writeLatin1(bytes, false) catch { return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; }; } - - this.registerAutoFlusher(); - return .{ .owned = len }; } pub fn writeUTF16(this: *@This(), data: Result) Result.Writable { @@ -1598,21 +1472,14 @@ pub const NetworkSink = struct { return .{ .owned = 0 }; } const bytes = data.slice(); - // we must always buffer UTF-16 - // we assume the case of all-ascii UTF-16 string is pretty uncommon - this.buffer.writeUTF16(@alignCast(std.mem.bytesAsSlice(u16, bytes))) catch { - return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; - }; - - const readable = this.buffer.slice(); - if (readable.len >= this.getHighWaterMark()) { - _ = this.internalFlush() catch { + if (this.task) |task| { + // we must always buffer UTF-16 + // we assume the case of all-ascii UTF-16 string is pretty uncommon + _ = task.writeUTF16(bytes, false) catch { return .{ .err = Syscall.Error.fromCode(.NOMEM, .write) }; }; - return .{ .owned = @as(Blob.SizeType, @intCast(bytes.len)) }; } - this.registerAutoFlusher(); return .{ .owned = @as(Blob.SizeType, @intCast(bytes.len)) }; } @@ -1624,26 +1491,33 @@ pub const NetworkSink = struct { // send EOF this.ended = true; // flush everything and send EOF - _ = this.internalFlush() catch 0; + if (this.task) |task| { + _ = task.writeBytes("", true) catch bun.outOfMemory(); + } this.signal.close(err); return .{ .result = {} }; } pub fn endFromJS(this: *@This(), _: *JSGlobalObject) JSC.Maybe(JSValue) { - if (!this.ended) { - if (this.done) { + _ = this.end(null); + if (this.endPromise.hasValue()) { + // we are already waiting for the end + return .{ .result = this.endPromise.value() }; + } + if (this.task) |task| { + // we need to wait for the task to end + this.endPromise = JSC.JSPromise.Strong.init(this.globalThis); + const value = this.endPromise.value(); + if (!this.ended) { this.ended = true; + // we need to send EOF + _ = task.writeBytes("", true) catch bun.outOfMemory(); this.signal.close(null); - this.finalize(); - } else { - _ = this.end(null); } + return .{ .result = value }; } - const promise = this.endPromise.valueOrEmpty(); - if (promise.isEmptyOrUndefinedOrNull()) { - return .{ .result = JSC.JSValue.jsNumber(0) }; - } - return .{ .result = promise }; + // task already detached + return .{ .result = JSC.JSValue.jsNumber(0) }; } pub fn toJS(this: *@This(), globalThis: *JSGlobalObject) JSValue { return JSSink.createObject(globalThis, this, 0); @@ -1651,7 +1525,11 @@ pub const NetworkSink = struct { pub fn memoryCost(this: *const @This()) usize { // Since this is a JSSink, the NewJSSink function does @sizeOf(JSSink) which includes @sizeOf(ArrayBufferSink). - return this.buffer.memoryCost(); + if (this.task) |task| { + //TODO: we could do better here + return task.buffered.memoryCost(); + } + return 0; } pub const name = "NetworkSink"; diff --git a/src/deps/uws/UpgradedDuplex.zig b/src/deps/uws/UpgradedDuplex.zig index 8008abd6d8..540c205a34 100644 --- a/src/deps/uws/UpgradedDuplex.zig +++ b/src/deps/uws/UpgradedDuplex.zig @@ -358,15 +358,15 @@ pub fn startTLS(this: *UpgradedDuplex, ssl_options: JSC.API.ServerConfig.SSLConf this.wrapper.?.start(); } -pub fn encodeAndWrite(this: *UpgradedDuplex, data: []const u8, is_end: bool) i32 { - log("encodeAndWrite (len: {} - is_end: {})", .{ data.len, is_end }); +pub fn encodeAndWrite(this: *UpgradedDuplex, data: []const u8) i32 { + log("encodeAndWrite (len: {})", .{data.len}); if (this.wrapper) |*wrapper| { return @as(i32, @intCast(wrapper.writeData(data) catch 0)); } return 0; } -pub fn rawWrite(this: *UpgradedDuplex, encoded_data: []const u8, _: bool) i32 { +pub fn rawWrite(this: *UpgradedDuplex, encoded_data: []const u8) i32 { this.internalWrite(encoded_data); return @intCast(encoded_data.len); } diff --git a/src/deps/uws/WindowsNamedPipe.zig b/src/deps/uws/WindowsNamedPipe.zig index 21939374d8..0b65cd0c93 100644 --- a/src/deps/uws/WindowsNamedPipe.zig +++ b/src/deps/uws/WindowsNamedPipe.zig @@ -459,8 +459,8 @@ pub fn isTLS(this: *WindowsNamedPipe) bool { return this.flags.is_ssl; } -pub fn encodeAndWrite(this: *WindowsNamedPipe, data: []const u8, is_end: bool) i32 { - log("encodeAndWrite (len: {} - is_end: {})", .{ data.len, is_end }); +pub fn encodeAndWrite(this: *WindowsNamedPipe, data: []const u8) i32 { + log("encodeAndWrite (len: {})", .{data.len}); if (this.wrapper) |*wrapper| { return @as(i32, @intCast(wrapper.writeData(data) catch 0)); } else { @@ -469,7 +469,7 @@ pub fn encodeAndWrite(this: *WindowsNamedPipe, data: []const u8, is_end: bool) i return @intCast(data.len); } -pub fn rawWrite(this: *WindowsNamedPipe, encoded_data: []const u8, _: bool) i32 { +pub fn rawWrite(this: *WindowsNamedPipe, encoded_data: []const u8) i32 { this.internalWrite(encoded_data); return @intCast(encoded_data.len); } diff --git a/src/deps/uws/socket.zig b/src/deps/uws/socket.zig index b2f8d7c22b..7d382c8887 100644 --- a/src/deps/uws/socket.zig +++ b/src/deps/uws/socket.zig @@ -317,29 +317,29 @@ pub fn NewSocketHandler(comptime is_ssl: bool) type { } } - pub fn write(this: ThisSocket, data: []const u8, msg_more: bool) i32 { + pub fn write(this: ThisSocket, data: []const u8) i32 { return switch (this.socket) { - .upgradedDuplex => |socket| socket.encodeAndWrite(data, msg_more), - .pipe => |pipe| if (comptime Environment.isWindows) pipe.encodeAndWrite(data, msg_more) else 0, - .connected => |socket| socket.write(is_ssl, data, msg_more), + .upgradedDuplex => |socket| socket.encodeAndWrite(data), + .pipe => |pipe| if (comptime Environment.isWindows) pipe.encodeAndWrite(data) else 0, + .connected => |socket| socket.write(is_ssl, data), .connecting, .detached => 0, }; } pub fn writeFd(this: ThisSocket, data: []const u8, file_descriptor: bun.FileDescriptor) i32 { return switch (this.socket) { - .upgradedDuplex, .pipe => this.write(data, false), + .upgradedDuplex, .pipe => this.write(data), .connected => |socket| socket.writeFd(data, file_descriptor), .connecting, .detached => 0, }; } - pub fn rawWrite(this: ThisSocket, data: []const u8, msg_more: bool) i32 { + pub fn rawWrite(this: ThisSocket, data: []const u8) i32 { return switch (this.socket) { - .connected => |socket| socket.rawWrite(is_ssl, data, msg_more), + .connected => |socket| socket.rawWrite(is_ssl, data), .connecting, .detached => 0, - .upgradedDuplex => |socket| socket.rawWrite(data, msg_more), - .pipe => |pipe| if (comptime Environment.isWindows) pipe.rawWrite(data, msg_more) else 0, + .upgradedDuplex => |socket| socket.rawWrite(data), + .pipe => |pipe| if (comptime Environment.isWindows) pipe.rawWrite(data) else 0, }; } @@ -1136,10 +1136,10 @@ pub const AnySocket = union(enum) { } } - pub fn write(this: AnySocket, data: []const u8, msg_more: bool) i32 { + pub fn write(this: AnySocket, data: []const u8) i32 { return switch (this) { - .SocketTCP => |sock| sock.write(data, msg_more), - .SocketTLS => |sock| sock.write(data, msg_more), + .SocketTCP => |sock| sock.write(data), + .SocketTLS => |sock| sock.write(data), }; } diff --git a/src/deps/uws/us_socket_t.zig b/src/deps/uws/us_socket_t.zig index 2c8d3fa328..3d530ad562 100644 --- a/src/deps/uws/us_socket_t.zig +++ b/src/deps/uws/us_socket_t.zig @@ -132,8 +132,8 @@ pub const us_socket_t = opaque { return c.us_socket_context(@intFromBool(ssl), this).?; } - pub fn write(this: *us_socket_t, ssl: bool, data: []const u8, msg_more: bool) i32 { - const rc = c.us_socket_write(@intFromBool(ssl), this, data.ptr, @intCast(data.len), @intFromBool(msg_more)); + pub fn write(this: *us_socket_t, ssl: bool, data: []const u8) i32 { + const rc = c.us_socket_write(@intFromBool(ssl), this, data.ptr, @intCast(data.len)); debug("us_socket_write({d}, {d}) = {d}", .{ @intFromPtr(this), data.len, rc }); return rc; } @@ -151,9 +151,9 @@ pub const us_socket_t = opaque { return rc; } - pub fn rawWrite(this: *us_socket_t, ssl: bool, data: []const u8, msg_more: bool) i32 { + pub fn rawWrite(this: *us_socket_t, ssl: bool, data: []const u8) i32 { debug("us_socket_raw_write({d}, {d})", .{ @intFromPtr(this), data.len }); - return c.us_socket_raw_write(@intFromBool(ssl), this, data.ptr, @intCast(data.len), @intFromBool(msg_more)); + return c.us_socket_raw_write(@intFromBool(ssl), this, data.ptr, @intCast(data.len)); } pub fn flush(this: *us_socket_t, ssl: bool) void { @@ -204,10 +204,10 @@ pub const c = struct { pub extern fn us_socket_ext(ssl: i32, s: ?*us_socket_t) ?*anyopaque; // nullish to be safe pub extern fn us_socket_context(ssl: i32, s: ?*us_socket_t) ?*SocketContext; - pub extern fn us_socket_write(ssl: i32, s: ?*us_socket_t, data: [*c]const u8, length: i32, msg_more: i32) i32; + pub extern fn us_socket_write(ssl: i32, s: ?*us_socket_t, data: [*c]const u8, length: i32) i32; pub extern fn us_socket_ipc_write_fd(s: ?*us_socket_t, data: [*c]const u8, length: i32, fd: i32) i32; pub extern fn us_socket_write2(ssl: i32, *us_socket_t, header: ?[*]const u8, len: usize, payload: ?[*]const u8, usize) i32; - pub extern fn us_socket_raw_write(ssl: i32, s: ?*us_socket_t, data: [*c]const u8, length: i32, msg_more: i32) i32; + pub extern fn us_socket_raw_write(ssl: i32, s: ?*us_socket_t, data: [*c]const u8, length: i32) i32; pub extern fn us_socket_flush(ssl: i32, s: ?*us_socket_t) void; // if a TLS socket calls this, it will start SSL instance and call open event will also do TLS handshake if required diff --git a/src/http.zig b/src/http.zig index 3084a08d67..7c9659e0eb 100644 --- a/src/http.zig +++ b/src/http.zig @@ -1,55 +1,12 @@ -const bun = @import("bun"); -const picohttp = bun.picohttp; -const JSC = bun.JSC; -const string = bun.string; -const Output = bun.Output; -const Global = bun.Global; -const Environment = bun.Environment; -const strings = bun.strings; -const MutableString = bun.MutableString; -const FeatureFlags = bun.FeatureFlags; -const stringZ = bun.stringZ; - -const Loc = bun.logger.Loc; -const Log = bun.logger.Log; -const DotEnv = @import("./env_loader.zig"); -const std = @import("std"); -const URL = @import("./url.zig").URL; -const PercentEncoding = @import("./url.zig").PercentEncoding; -pub const Method = @import("./http/method.zig").Method; -const Api = @import("./api/schema.zig").Api; -const HTTPClient = @This(); -const Zlib = @import("./zlib.zig"); -const Brotli = bun.brotli; -const zstd = bun.zstd; -const StringBuilder = bun.StringBuilder; -const ThreadPool = bun.ThreadPool; -const posix = std.posix; -const SOCK = posix.SOCK; -const Arena = @import("./allocators/mimalloc_arena.zig").Arena; -const BoringSSL = bun.BoringSSL.c; -const Progress = bun.Progress; -const SSLConfig = @import("./bun.js/api/server.zig").ServerConfig.SSLConfig; -const SSLWrapper = @import("./bun.js/api/bun/ssl_wrapper.zig").SSLWrapper; -const Blob = bun.webcore.Blob; -const FetchHeaders = bun.webcore.FetchHeaders; -const uws = bun.uws; -pub const MimeType = @import("./http/mime_type.zig"); -pub const URLPath = @import("./http/url_path.zig"); // This becomes Arena.allocator pub var default_allocator: std.mem.Allocator = undefined; -var default_arena: Arena = undefined; +pub var default_arena: Arena = undefined; pub var http_thread: HTTPThread = undefined; -const HiveArray = @import("./hive_array.zig").HiveArray; -const Batch = bun.ThreadPool.Batch; -const TaggedPointerUnion = @import("./ptr.zig").TaggedPointerUnion; -const DeadSocket = opaque {}; -var dead_socket = @as(*DeadSocket, @ptrFromInt(1)); + //TODO: this needs to be freed when Worker Threads are implemented -var socket_async_http_abort_tracker = std.AutoArrayHashMap(u32, uws.InternalSocket).init(bun.default_allocator); -var async_http_id_monotonic: std.atomic.Value(u32) = std.atomic.Value(u32).init(0); +pub var socket_async_http_abort_tracker = std.AutoArrayHashMap(u32, uws.InternalSocket).init(bun.default_allocator); +pub var async_http_id_monotonic: std.atomic.Value(u32) = std.atomic.Value(u32).init(0); const MAX_REDIRECT_URL_LENGTH = 128 * 1024; -var custom_ssl_context_map = std.AutoArrayHashMap(*SSLConfig, *NewHTTPContext(true)).init(bun.default_allocator); pub var max_http_header_size: usize = 16 * 1024; comptime { @@ -68,1481 +25,9 @@ var shared_response_headers_buf: [256]picohttp.Header = undefined; pub const end_of_chunked_http1_1_encoding_response_body = "0\r\n\r\n"; -pub const Signals = struct { - header_progress: ?*std.atomic.Value(bool) = null, - body_streaming: ?*std.atomic.Value(bool) = null, - aborted: ?*std.atomic.Value(bool) = null, - cert_errors: ?*std.atomic.Value(bool) = null, - - pub fn isEmpty(this: *const Signals) bool { - return this.aborted == null and this.body_streaming == null and this.header_progress == null and this.cert_errors == null; - } - - pub const Store = struct { - header_progress: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - body_streaming: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - aborted: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - cert_errors: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - - pub fn to(this: *Store) Signals { - return .{ - .header_progress = &this.header_progress, - .body_streaming = &this.body_streaming, - .aborted = &this.aborted, - .cert_errors = &this.cert_errors, - }; - } - }; - - pub fn get(this: Signals, comptime field: std.meta.FieldEnum(Signals)) bool { - var ptr: *std.atomic.Value(bool) = @field(this, @tagName(field)) orelse return false; - return ptr.load(.monotonic); - } -}; - -pub const FetchRedirect = enum(u8) { - follow, - manual, - @"error", - - pub const Map = bun.ComptimeStringMap(FetchRedirect, .{ - .{ "follow", .follow }, - .{ "manual", .manual }, - .{ "error", .@"error" }, - }); -}; - -pub const HTTPRequestBody = union(enum) { - bytes: []const u8, - sendfile: Sendfile, - stream: struct { - buffer: bun.io.StreamBuffer, - ended: bool, - has_backpressure: bool = false, - - pub fn hasEnded(this: *@This()) bool { - return this.ended and this.buffer.isEmpty(); - } - }, - - pub fn isStream(this: *const HTTPRequestBody) bool { - return this.* == .stream; - } - - pub fn deinit(this: *HTTPRequestBody) void { - switch (this.*) { - .sendfile, .bytes => {}, - .stream => |*stream| stream.buffer.deinit(), - } - } - pub fn len(this: *const HTTPRequestBody) usize { - return switch (this.*) { - .bytes => this.bytes.len, - .sendfile => this.sendfile.content_size, - // unknow amounts - .stream => std.math.maxInt(usize), - }; - } -}; - -pub const Sendfile = struct { - fd: bun.FileDescriptor, - remain: usize = 0, - offset: usize = 0, - content_size: usize = 0, - - pub fn isEligible(url: bun.URL) bool { - if (comptime Environment.isWindows or !FeatureFlags.streaming_file_uploads_for_http_client) { - return false; - } - return url.isHTTP() and url.href.len > 0; - } - - pub fn write( - this: *Sendfile, - socket: NewHTTPContext(false).HTTPSocket, - ) Status { - const adjusted_count_temporary = @min(@as(u64, this.remain), @as(u63, std.math.maxInt(u63))); - // TODO we should not need this int cast; improve the return type of `@min` - const adjusted_count = @as(u63, @intCast(adjusted_count_temporary)); - - if (Environment.isLinux) { - var signed_offset = @as(i64, @intCast(this.offset)); - const begin = this.offset; - const val = - // this does the syscall directly, without libc - std.os.linux.sendfile(socket.fd().cast(), this.fd.cast(), &signed_offset, this.remain); - this.offset = @as(u64, @intCast(signed_offset)); - - const errcode = bun.sys.getErrno(val); - - this.remain -|= @as(u64, @intCast(this.offset -| begin)); - - if (errcode != .SUCCESS or this.remain == 0 or val == 0) { - if (errcode == .SUCCESS) { - return .{ .done = {} }; - } - - return .{ .err = bun.errnoToZigErr(errcode) }; - } - } else if (Environment.isPosix) { - var sbytes: std.posix.off_t = adjusted_count; - const signed_offset = @as(i64, @bitCast(@as(u64, this.offset))); - const errcode = bun.sys.getErrno(std.c.sendfile( - this.fd.cast(), - socket.fd().cast(), - signed_offset, - &sbytes, - null, - 0, - )); - const wrote = @as(u64, @intCast(sbytes)); - this.offset +|= wrote; - this.remain -|= wrote; - if (errcode != .AGAIN or this.remain == 0 or sbytes == 0) { - if (errcode == .SUCCESS) { - return .{ .done = {} }; - } - - return .{ .err = bun.errnoToZigErr(errcode) }; - } - } - - return .{ .again = {} }; - } - - pub const Status = union(enum) { - done: void, - err: anyerror, - again: void, - }; -}; - -const ProxyTunnel = struct { - const RefCount = bun.ptr.RefCount(@This(), "ref_count", ProxyTunnel.deinit, .{}); - pub const ref = ProxyTunnel.RefCount.ref; - pub const deref = ProxyTunnel.RefCount.deref; - - wrapper: ?ProxyTunnelWrapper = null, - shutdown_err: anyerror = error.ConnectionClosed, - // active socket is the socket that is currently being used - socket: union(enum) { - tcp: NewHTTPContext(false).HTTPSocket, - ssl: NewHTTPContext(true).HTTPSocket, - none: void, - } = .{ .none = {} }, - write_buffer: bun.io.StreamBuffer = .{}, - ref_count: RefCount, - - const ProxyTunnelWrapper = SSLWrapper(*HTTPClient); - - fn onOpen(this: *HTTPClient) void { - log("ProxyTunnel onOpen", .{}); - this.state.response_stage = .proxy_handshake; - this.state.request_stage = .proxy_handshake; - if (this.proxy_tunnel) |proxy| { - proxy.ref(); - defer proxy.deref(); - if (proxy.wrapper) |*wrapper| { - var ssl_ptr = wrapper.ssl orelse return; - const _hostname = this.hostname orelse this.url.hostname; - - var hostname: [:0]const u8 = ""; - var hostname_needs_free = false; - if (!strings.isIPAddress(_hostname)) { - if (_hostname.len < temp_hostname.len) { - @memcpy(temp_hostname[0.._hostname.len], _hostname); - temp_hostname[_hostname.len] = 0; - hostname = temp_hostname[0.._hostname.len :0]; - } else { - hostname = bun.default_allocator.dupeZ(u8, _hostname) catch unreachable; - hostname_needs_free = true; - } - } - - defer if (hostname_needs_free) bun.default_allocator.free(hostname); - ssl_ptr.configureHTTPClient(hostname); - } - } - } - - fn onData(this: *HTTPClient, decoded_data: []const u8) void { - if (decoded_data.len == 0) return; - log("ProxyTunnel onData decoded {}", .{decoded_data.len}); - if (this.proxy_tunnel) |proxy| { - proxy.ref(); - defer proxy.deref(); - switch (this.state.response_stage) { - .body => { - log("ProxyTunnel onData body", .{}); - if (decoded_data.len == 0) return; - const report_progress = this.handleResponseBody(decoded_data, false) catch |err| { - proxy.close(err); - return; - }; - - if (report_progress) { - switch (proxy.socket) { - .ssl => |socket| { - this.progressUpdate(true, &http_thread.https_context, socket); - }, - .tcp => |socket| { - this.progressUpdate(false, &http_thread.http_context, socket); - }, - .none => {}, - } - return; - } - }, - .body_chunk => { - log("ProxyTunnel onData body_chunk", .{}); - if (decoded_data.len == 0) return; - const report_progress = this.handleResponseBodyChunkedEncoding(decoded_data) catch |err| { - proxy.close(err); - return; - }; - - if (report_progress) { - switch (proxy.socket) { - .ssl => |socket| { - this.progressUpdate(true, &http_thread.https_context, socket); - }, - .tcp => |socket| { - this.progressUpdate(false, &http_thread.http_context, socket); - }, - .none => {}, - } - return; - } - }, - .proxy_headers => { - log("ProxyTunnel onData proxy_headers", .{}); - switch (proxy.socket) { - .ssl => |socket| { - this.handleOnDataHeaders(true, decoded_data, &http_thread.https_context, socket); - }, - .tcp => |socket| { - this.handleOnDataHeaders(false, decoded_data, &http_thread.http_context, socket); - }, - .none => {}, - } - }, - else => { - log("ProxyTunnel onData unexpected data", .{}); - this.state.pending_response = null; - proxy.close(error.UnexpectedData); - }, - } - } - } - - fn onHandshake(this: *HTTPClient, handshake_success: bool, ssl_error: uws.us_bun_verify_error_t) void { - if (this.proxy_tunnel) |proxy| { - log("ProxyTunnel onHandshake", .{}); - proxy.ref(); - defer proxy.deref(); - this.state.response_stage = .proxy_headers; - this.state.request_stage = .proxy_headers; - this.state.request_sent_len = 0; - const handshake_error = HTTPCertError{ - .error_no = ssl_error.error_no, - .code = if (ssl_error.code == null) "" else ssl_error.code[0..bun.len(ssl_error.code) :0], - .reason = if (ssl_error.code == null) "" else ssl_error.reason[0..bun.len(ssl_error.reason) :0], - }; - if (handshake_success) { - log("ProxyTunnel onHandshake success", .{}); - // handshake completed but we may have ssl errors - this.flags.did_have_handshaking_error = handshake_error.error_no != 0; - if (this.flags.reject_unauthorized) { - // only reject the connection if reject_unauthorized == true - if (this.flags.did_have_handshaking_error) { - proxy.close(BoringSSL.getCertErrorFromNo(handshake_error.error_no)); - return; - } - - // if checkServerIdentity returns false, we dont call open this means that the connection was rejected - bun.assert(proxy.wrapper != null); - const ssl_ptr = proxy.wrapper.?.ssl orelse return; - - switch (proxy.socket) { - .ssl => |socket| { - if (!this.checkServerIdentity(true, socket, handshake_error, ssl_ptr, false)) { - log("ProxyTunnel onHandshake checkServerIdentity failed", .{}); - this.flags.did_have_handshaking_error = true; - - this.unregisterAbortTracker(); - return; - } - }, - .tcp => |socket| { - if (!this.checkServerIdentity(false, socket, handshake_error, ssl_ptr, false)) { - log("ProxyTunnel onHandshake checkServerIdentity failed", .{}); - this.flags.did_have_handshaking_error = true; - this.unregisterAbortTracker(); - return; - } - }, - .none => {}, - } - } - - switch (proxy.socket) { - .ssl => |socket| { - this.onWritable(true, true, socket); - }, - .tcp => |socket| { - this.onWritable(true, false, socket); - }, - .none => {}, - } - } else { - log("ProxyTunnel onHandshake failed", .{}); - // if we are here is because server rejected us, and the error_no is the cause of this - // if we set reject_unauthorized == false this means the server requires custom CA aka NODE_EXTRA_CA_CERTS - if (this.flags.did_have_handshaking_error and handshake_error.error_no != 0) { - proxy.close(BoringSSL.getCertErrorFromNo(handshake_error.error_no)); - return; - } - // if handshake_success it self is false, this means that the connection was rejected - proxy.close(error.ConnectionRefused); - return; - } - } - } - - pub fn write(this: *HTTPClient, encoded_data: []const u8) void { - if (this.proxy_tunnel) |proxy| { - const written = switch (proxy.socket) { - .ssl => |socket| socket.write(encoded_data, false), - .tcp => |socket| socket.write(encoded_data, false), - .none => 0, - }; - const pending = encoded_data[@intCast(written)..]; - if (pending.len > 0) { - // lets flush when we are truly writable - proxy.write_buffer.write(pending) catch bun.outOfMemory(); - } - } - } - - fn onClose(this: *HTTPClient) void { - log("ProxyTunnel onClose {s}", .{if (this.proxy_tunnel == null) "tunnel is detached" else "tunnel exists"}); - if (this.proxy_tunnel) |proxy| { - proxy.ref(); - // defer the proxy deref the proxy tunnel may still be in use after triggering the close callback - defer http_thread.scheduleProxyDeref(proxy); - const err = proxy.shutdown_err; - switch (proxy.socket) { - .ssl => |socket| { - this.closeAndFail(err, true, socket); - }, - .tcp => |socket| { - this.closeAndFail(err, false, socket); - }, - .none => {}, - } - proxy.detachSocket(); - } - } - - fn start(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket, ssl_options: JSC.API.ServerConfig.SSLConfig, start_payload: []const u8) void { - const proxy_tunnel = bun.new(ProxyTunnel, .{ - .ref_count = .init(), - }); - - var custom_options = ssl_options; - // we always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match - custom_options.reject_unauthorized = 0; - custom_options.request_cert = 1; - proxy_tunnel.wrapper = SSLWrapper(*HTTPClient).init(custom_options, true, .{ - .onOpen = ProxyTunnel.onOpen, - .onData = ProxyTunnel.onData, - .onHandshake = ProxyTunnel.onHandshake, - .onClose = ProxyTunnel.onClose, - .write = ProxyTunnel.write, - .ctx = this, - }) catch |err| { - if (err == error.OutOfMemory) { - bun.outOfMemory(); - } - - // invalid TLS Options - proxy_tunnel.detachAndDeref(); - this.closeAndFail(error.ConnectionRefused, is_ssl, socket); - return; - }; - this.proxy_tunnel = proxy_tunnel; - if (is_ssl) { - proxy_tunnel.socket = .{ .ssl = socket }; - } else { - proxy_tunnel.socket = .{ .tcp = socket }; - } - if (start_payload.len > 0) { - log("proxy tunnel start with payload", .{}); - proxy_tunnel.wrapper.?.startWithPayload(start_payload); - } else { - log("proxy tunnel start", .{}); - proxy_tunnel.wrapper.?.start(); - } - } - - pub fn close(this: *ProxyTunnel, err: anyerror) void { - this.shutdown_err = err; - this.shutdown(); - } - - pub fn shutdown(this: *ProxyTunnel) void { - if (this.wrapper) |*wrapper| { - // fast shutdown the connection - _ = wrapper.shutdown(true); - } - } - - pub fn onWritable(this: *ProxyTunnel, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket) void { - log("ProxyTunnel onWritable", .{}); - this.ref(); - defer this.deref(); - defer if (this.wrapper) |*wrapper| { - // Cycle to through the SSL state machine - _ = wrapper.flush(); - }; - - const encoded_data = this.write_buffer.slice(); - if (encoded_data.len == 0) { - return; - } - const written = socket.write(encoded_data, true); - if (written == encoded_data.len) { - this.write_buffer.reset(); - } else { - this.write_buffer.cursor += @intCast(written); - } - } - - pub fn receiveData(this: *ProxyTunnel, buf: []const u8) void { - this.ref(); - defer this.deref(); - if (this.wrapper) |*wrapper| { - wrapper.receiveData(buf); - } - } - - pub fn writeData(this: *ProxyTunnel, buf: []const u8) !usize { - if (this.wrapper) |*wrapper| { - return try wrapper.writeData(buf); - } - return error.ConnectionClosed; - } - - pub fn detachSocket(this: *ProxyTunnel) void { - this.socket = .{ .none = {} }; - } - - pub fn detachAndDeref(this: *ProxyTunnel) void { - this.detachSocket(); - this.deref(); - } - - fn deinit(this: *ProxyTunnel) void { - this.socket = .{ .none = {} }; - if (this.wrapper) |*wrapper| { - wrapper.deinit(); - this.wrapper = null; - } - this.write_buffer.deinit(); - bun.destroy(this); - } -}; - -pub const HTTPCertError = struct { - error_no: i32 = 0, - code: [:0]const u8 = "", - reason: [:0]const u8 = "", -}; - -pub const InitError = error{ - FailedToOpenSocket, - LoadCAFile, - InvalidCAFile, - InvalidCA, -}; - -fn NewHTTPContext(comptime ssl: bool) type { - return struct { - const pool_size = 64; - const PooledSocket = struct { - http_socket: HTTPSocket, - hostname_buf: [MAX_KEEPALIVE_HOSTNAME]u8 = undefined, - hostname_len: u8 = 0, - port: u16 = 0, - /// If you set `rejectUnauthorized` to `false`, the connection fails to verify, - did_have_handshaking_error_while_reject_unauthorized_is_false: bool = false, - }; - - pub fn markSocketAsDead(socket: HTTPSocket) void { - if (socket.ext(**anyopaque)) |ctx| { - ctx.* = bun.cast(**anyopaque, ActiveSocket.init(&dead_socket).ptr()); - } - } - - fn terminateSocket(socket: HTTPSocket) void { - markSocketAsDead(socket); - socket.close(.failure); - } - - fn closeSocket(socket: HTTPSocket) void { - markSocketAsDead(socket); - socket.close(.normal); - } - - fn getTagged(ptr: *anyopaque) ActiveSocket { - return ActiveSocket.from(bun.cast(**anyopaque, ptr).*); - } - - pub fn getTaggedFromSocket(socket: HTTPSocket) ActiveSocket { - if (socket.ext(anyopaque)) |ctx| { - return getTagged(ctx); - } - return ActiveSocket.init(&dead_socket); - } - - pub const PooledSocketHiveAllocator = bun.HiveArray(PooledSocket, pool_size); - - pending_sockets: PooledSocketHiveAllocator, - us_socket_context: *uws.SocketContext, - - const Context = @This(); - pub const HTTPSocket = uws.NewSocketHandler(ssl); - - pub fn context() *@This() { - if (comptime ssl) { - return &http_thread.https_context; - } else { - return &http_thread.http_context; - } - } - - const ActiveSocket = TaggedPointerUnion(.{ - *DeadSocket, - HTTPClient, - PooledSocket, - }); - const ssl_int = @as(c_int, @intFromBool(ssl)); - - const MAX_KEEPALIVE_HOSTNAME = 128; - - pub fn sslCtx(this: *@This()) *BoringSSL.SSL_CTX { - if (comptime !ssl) { - unreachable; - } - - return @as(*BoringSSL.SSL_CTX, @ptrCast(this.us_socket_context.getNativeHandle(true))); - } - - pub fn deinit(this: *@This()) void { - this.us_socket_context.deinit(ssl); - bun.default_allocator.destroy(this); - } - - pub fn initWithClientConfig(this: *@This(), client: *HTTPClient) InitError!void { - if (!comptime ssl) { - @compileError("ssl only"); - } - var opts = client.tls_props.?.asUSockets(); - opts.request_cert = 1; - opts.reject_unauthorized = 0; - try this.initWithOpts(&opts); - } - - fn initWithOpts(this: *@This(), opts: *const uws.SocketContext.BunSocketContextOptions) InitError!void { - if (!comptime ssl) { - @compileError("ssl only"); - } - - var err: uws.create_bun_socket_error_t = .none; - const socket = uws.SocketContext.createSSLContext(http_thread.loop.loop, @sizeOf(usize), opts.*, &err); - if (socket == null) { - return switch (err) { - .load_ca_file => error.LoadCAFile, - .invalid_ca_file => error.InvalidCAFile, - .invalid_ca => error.InvalidCA, - else => error.FailedToOpenSocket, - }; - } - this.us_socket_context = socket.?; - this.sslCtx().setup(); - - HTTPSocket.configure( - this.us_socket_context, - false, - anyopaque, - Handler, - ); - } - - pub fn initWithThreadOpts(this: *@This(), init_opts: *const HTTPThread.InitOpts) InitError!void { - if (!comptime ssl) { - @compileError("ssl only"); - } - var opts: uws.SocketContext.BunSocketContextOptions = .{ - .ca = if (init_opts.ca.len > 0) @ptrCast(init_opts.ca) else null, - .ca_count = @intCast(init_opts.ca.len), - .ca_file_name = if (init_opts.abs_ca_file_name.len > 0) init_opts.abs_ca_file_name else null, - .request_cert = 1, - }; - - try this.initWithOpts(&opts); - } - - pub fn init(this: *@This()) void { - if (comptime ssl) { - const opts: uws.SocketContext.BunSocketContextOptions = .{ - // we request the cert so we load root certs and can verify it - .request_cert = 1, - // we manually abort the connection if the hostname doesn't match - .reject_unauthorized = 0, - }; - var err: uws.create_bun_socket_error_t = .none; - this.us_socket_context = uws.SocketContext.createSSLContext(http_thread.loop.loop, @sizeOf(usize), opts, &err).?; - - this.sslCtx().setup(); - } else { - this.us_socket_context = uws.SocketContext.createNoSSLContext(http_thread.loop.loop, @sizeOf(usize)).?; - } - - HTTPSocket.configure( - this.us_socket_context, - false, - anyopaque, - Handler, - ); - } - - /// Attempt to keep the socket alive by reusing it for another request. - /// If no space is available, close the socket. - /// - /// If `did_have_handshaking_error_while_reject_unauthorized_is_false` - /// is set, then we can only reuse the socket for HTTP Keep Alive if - /// `reject_unauthorized` is set to `false`. - pub fn releaseSocket(this: *@This(), socket: HTTPSocket, did_have_handshaking_error_while_reject_unauthorized_is_false: bool, hostname: []const u8, port: u16) void { - // log("releaseSocket(0x{})", .{bun.fmt.hexIntUpper(@intFromPtr(socket.socket))}); - - if (comptime Environment.allow_assert) { - assert(!socket.isClosed()); - assert(!socket.isShutdown()); - assert(socket.isEstablished()); - } - assert(hostname.len > 0); - assert(port > 0); - - if (hostname.len <= MAX_KEEPALIVE_HOSTNAME and !socket.isClosedOrHasError() and socket.isEstablished()) { - if (this.pending_sockets.get()) |pending| { - if (socket.ext(**anyopaque)) |ctx| { - ctx.* = bun.cast(**anyopaque, ActiveSocket.init(pending).ptr()); - } - socket.flush(); - socket.timeout(0); - socket.setTimeoutMinutes(5); - - pending.http_socket = socket; - pending.did_have_handshaking_error_while_reject_unauthorized_is_false = did_have_handshaking_error_while_reject_unauthorized_is_false; - @memcpy(pending.hostname_buf[0..hostname.len], hostname); - pending.hostname_len = @as(u8, @truncate(hostname.len)); - pending.port = port; - - log("Keep-Alive release {s}:{d}", .{ - hostname, - port, - }); - return; - } - } - log("close socket", .{}); - closeSocket(socket); - } - - pub const Handler = struct { - pub fn onOpen( - ptr: *anyopaque, - socket: HTTPSocket, - ) void { - const active = getTagged(ptr); - if (active.get(HTTPClient)) |client| { - if (client.onOpen(comptime ssl, socket)) |_| { - return; - } else |_| { - log("Unable to open socket", .{}); - terminateSocket(socket); - return; - } - } - - if (active.get(PooledSocket)) |pooled| { - addMemoryBackToPool(pooled); - return; - } - - log("Unexpected open on unknown socket", .{}); - terminateSocket(socket); - } - pub fn onHandshake( - ptr: *anyopaque, - socket: HTTPSocket, - success: i32, - ssl_error: uws.us_bun_verify_error_t, - ) void { - const handshake_success = if (success == 1) true else false; - - const handshake_error = HTTPCertError{ - .error_no = ssl_error.error_no, - .code = if (ssl_error.code == null) "" else ssl_error.code[0..bun.len(ssl_error.code) :0], - .reason = if (ssl_error.code == null) "" else ssl_error.reason[0..bun.len(ssl_error.reason) :0], - }; - - const active = getTagged(ptr); - if (active.get(HTTPClient)) |client| { - // handshake completed but we may have ssl errors - client.flags.did_have_handshaking_error = handshake_error.error_no != 0; - if (handshake_success) { - if (client.flags.reject_unauthorized) { - // only reject the connection if reject_unauthorized == true - if (client.flags.did_have_handshaking_error) { - client.closeAndFail(BoringSSL.getCertErrorFromNo(handshake_error.error_no), comptime ssl, socket); - return; - } - - // if checkServerIdentity returns false, we dont call open this means that the connection was rejected - const ssl_ptr = @as(*BoringSSL.SSL, @ptrCast(socket.getNativeHandle())); - if (!client.checkServerIdentity(comptime ssl, socket, handshake_error, ssl_ptr, true)) { - client.flags.did_have_handshaking_error = true; - client.unregisterAbortTracker(); - if (!socket.isClosed()) terminateSocket(socket); - return; - } - } - - return client.firstCall(comptime ssl, socket); - } else { - // if we are here is because server rejected us, and the error_no is the cause of this - // if we set reject_unauthorized == false this means the server requires custom CA aka NODE_EXTRA_CA_CERTS - if (client.flags.did_have_handshaking_error) { - client.closeAndFail(BoringSSL.getCertErrorFromNo(handshake_error.error_no), comptime ssl, socket); - return; - } - // if handshake_success it self is false, this means that the connection was rejected - client.closeAndFail(error.ConnectionRefused, comptime ssl, socket); - return; - } - } - - if (socket.isClosed()) { - markSocketAsDead(socket); - if (active.get(PooledSocket)) |pooled| { - addMemoryBackToPool(pooled); - } - - return; - } - - if (handshake_success) { - if (active.is(PooledSocket)) { - // Allow pooled sockets to be reused if the handshake was successful. - socket.setTimeout(0); - socket.setTimeoutMinutes(5); - return; - } - } - - if (active.get(PooledSocket)) |pooled| { - addMemoryBackToPool(pooled); - } - - terminateSocket(socket); - } - pub fn onClose( - ptr: *anyopaque, - socket: HTTPSocket, - _: c_int, - _: ?*anyopaque, - ) void { - const tagged = getTagged(ptr); - markSocketAsDead(socket); - - if (tagged.get(HTTPClient)) |client| { - return client.onClose(comptime ssl, socket); - } - - if (tagged.get(PooledSocket)) |pooled| { - addMemoryBackToPool(pooled); - } - - return; - } - - fn addMemoryBackToPool(pooled: *PooledSocket) void { - assert(context().pending_sockets.put(pooled)); - } - - pub fn onData( - ptr: *anyopaque, - socket: HTTPSocket, - buf: []const u8, - ) void { - const tagged = getTagged(ptr); - if (tagged.get(HTTPClient)) |client| { - return client.onData( - comptime ssl, - buf, - if (comptime ssl) &http_thread.https_context else &http_thread.http_context, - socket, - ); - } else if (tagged.is(PooledSocket)) { - // trailing zero is fine to ignore - if (strings.eqlComptime(buf, end_of_chunked_http1_1_encoding_response_body)) { - return; - } - - log("Unexpected data on socket", .{}); - - return; - } - log("Unexpected data on unknown socket", .{}); - terminateSocket(socket); - } - pub fn onWritable( - ptr: *anyopaque, - socket: HTTPSocket, - ) void { - const tagged = getTagged(ptr); - if (tagged.get(HTTPClient)) |client| { - return client.onWritable( - false, - comptime ssl, - socket, - ); - } else if (tagged.is(PooledSocket)) { - // it's a keep-alive socket - } else { - // don't know what this is, let's close it - log("Unexpected writable on socket", .{}); - terminateSocket(socket); - } - } - pub fn onLongTimeout( - ptr: *anyopaque, - socket: HTTPSocket, - ) void { - const tagged = getTagged(ptr); - if (tagged.get(HTTPClient)) |client| { - return client.onTimeout(comptime ssl, socket); - } else if (tagged.get(PooledSocket)) |pooled| { - // If a socket has been sitting around for 5 minutes - // Let's close it and remove it from the pool. - addMemoryBackToPool(pooled); - } - - terminateSocket(socket); - } - pub fn onConnectError( - ptr: *anyopaque, - socket: HTTPSocket, - _: c_int, - ) void { - const tagged = getTagged(ptr); - markSocketAsDead(socket); - if (tagged.get(HTTPClient)) |client| { - client.onConnectError(); - } else if (tagged.get(PooledSocket)) |pooled| { - addMemoryBackToPool(pooled); - } - // us_connecting_socket_close is always called internally by uSockets - } - pub fn onEnd( - _: *anyopaque, - socket: HTTPSocket, - ) void { - // TCP fin must be closed, but we must keep the original tagged - // pointer so that their onClose callback is called. - // - // Three possible states: - // 1. HTTP Keep-Alive socket: it must be removed from the pool - // 2. HTTP Client socket: it might need to be retried - // 3. Dead socket: it is already marked as dead - socket.close(.failure); - } - }; - - fn existingSocket(this: *@This(), reject_unauthorized: bool, hostname: []const u8, port: u16) ?HTTPSocket { - if (hostname.len > MAX_KEEPALIVE_HOSTNAME) - return null; - - var iter = this.pending_sockets.used.iterator(.{ .kind = .set }); - - while (iter.next()) |pending_socket_index| { - var socket = this.pending_sockets.at(@as(u16, @intCast(pending_socket_index))); - if (socket.port != port) { - continue; - } - - if (socket.did_have_handshaking_error_while_reject_unauthorized_is_false and reject_unauthorized) { - continue; - } - - if (strings.eqlLong(socket.hostname_buf[0..socket.hostname_len], hostname, true)) { - const http_socket = socket.http_socket; - assert(context().pending_sockets.put(socket)); - - if (http_socket.isClosed()) { - markSocketAsDead(http_socket); - continue; - } - - if (http_socket.isShutdown() or http_socket.getError() != 0) { - terminateSocket(http_socket); - continue; - } - - log("+ Keep-Alive reuse {s}:{d}", .{ hostname, port }); - return http_socket; - } - } - - return null; - } - - pub fn connectSocket(this: *@This(), client: *HTTPClient, socket_path: []const u8) !HTTPSocket { - client.connected_url = if (client.http_proxy) |proxy| proxy else client.url; - const socket = try HTTPSocket.connectUnixAnon( - socket_path, - this.us_socket_context, - ActiveSocket.init(client).ptr(), - false, // dont allow half-open sockets - ); - client.allow_retry = false; - return socket; - } - - pub fn connect(this: *@This(), client: *HTTPClient, hostname_: []const u8, port: u16) !HTTPSocket { - const hostname = if (FeatureFlags.hardcode_localhost_to_127_0_0_1 and strings.eqlComptime(hostname_, "localhost")) - "127.0.0.1" - else - hostname_; - - client.connected_url = if (client.http_proxy) |proxy| proxy else client.url; - client.connected_url.hostname = hostname; - - if (client.isKeepAlivePossible()) { - if (this.existingSocket(client.flags.reject_unauthorized, hostname, port)) |sock| { - if (sock.ext(**anyopaque)) |ctx| { - ctx.* = bun.cast(**anyopaque, ActiveSocket.init(client).ptr()); - } - client.allow_retry = true; - try client.onOpen(comptime ssl, sock); - if (comptime ssl) { - client.firstCall(comptime ssl, sock); - } - return sock; - } - } - - const socket = try HTTPSocket.connectAnon( - hostname, - port, - this.us_socket_context, - ActiveSocket.init(client).ptr(), - false, - ); - client.allow_retry = false; - return socket; - } - }; -} - -const UnboundedQueue = @import("./bun.js/unbounded_queue.zig").UnboundedQueue; -const Queue = UnboundedQueue(AsyncHTTP, .next); - -pub const HTTPThread = struct { - loop: *JSC.MiniEventLoop, - http_context: NewHTTPContext(false), - https_context: NewHTTPContext(true), - - queued_tasks: Queue = Queue{}, - - queued_shutdowns: std.ArrayListUnmanaged(ShutdownMessage) = std.ArrayListUnmanaged(ShutdownMessage){}, - queued_writes: std.ArrayListUnmanaged(WriteMessage) = std.ArrayListUnmanaged(WriteMessage){}, - - queued_shutdowns_lock: bun.Mutex = .{}, - queued_writes_lock: bun.Mutex = .{}, - - queued_proxy_deref: std.ArrayListUnmanaged(*ProxyTunnel) = std.ArrayListUnmanaged(*ProxyTunnel){}, - - has_awoken: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - timer: std.time.Timer, - lazy_libdeflater: ?*LibdeflateState = null, - lazy_request_body_buffer: ?*HeapRequestBodyBuffer = null, - - pub const HeapRequestBodyBuffer = struct { - buffer: [512 * 1024]u8 = undefined, - fixed_buffer_allocator: std.heap.FixedBufferAllocator, - - pub const new = bun.TrivialNew(@This()); - pub const deinit = bun.TrivialDeinit(@This()); - - pub fn init() *@This() { - var this = HeapRequestBodyBuffer.new(.{ - .fixed_buffer_allocator = undefined, - }); - this.fixed_buffer_allocator = std.heap.FixedBufferAllocator.init(&this.buffer); - return this; - } - - pub fn put(this: *@This()) void { - if (http_thread.lazy_request_body_buffer == null) { - // This case hypothetically should never happen - this.fixed_buffer_allocator.reset(); - http_thread.lazy_request_body_buffer = this; - } else { - this.deinit(); - } - } - }; - - pub const RequestBodyBuffer = union(enum) { - heap: *HeapRequestBodyBuffer, - stack: std.heap.StackFallbackAllocator(request_body_send_stack_buffer_size), - - pub fn deinit(this: *@This()) void { - switch (this.*) { - .heap => |heap| heap.put(), - .stack => {}, - } - } - - pub fn allocatedSlice(this: *@This()) []u8 { - return switch (this.*) { - .heap => |heap| &heap.buffer, - .stack => |*stack| &stack.buffer, - }; - } - - pub fn allocator(this: *@This()) std.mem.Allocator { - return switch (this.*) { - .heap => |heap| heap.fixed_buffer_allocator.allocator(), - .stack => |*stack| stack.get(), - }; - } - - pub fn toArrayList(this: *@This()) std.ArrayList(u8) { - var arraylist = std.ArrayList(u8).fromOwnedSlice(this.allocator(), this.allocatedSlice()); - arraylist.items.len = 0; - return arraylist; - } - }; - - const threadlog = Output.scoped(.HTTPThread, true); - const WriteMessage = struct { - data: []const u8, - async_http_id: u32, - flags: packed struct(u8) { - is_tls: bool, - ended: bool, - _: u6 = 0, - }, - }; - const ShutdownMessage = struct { - async_http_id: u32, - is_tls: bool, - }; - - pub const LibdeflateState = struct { - decompressor: *bun.libdeflate.Decompressor = undefined, - shared_buffer: [512 * 1024]u8 = undefined, - - pub const new = bun.TrivialNew(@This()); - }; - - const request_body_send_stack_buffer_size = 32 * 1024; - - pub inline fn getRequestBodySendBuffer(this: *@This(), estimated_size: usize) RequestBodyBuffer { - if (estimated_size >= request_body_send_stack_buffer_size) { - if (this.lazy_request_body_buffer == null) { - log("Allocating HeapRequestBodyBuffer due to {d} bytes request body", .{estimated_size}); - return .{ - .heap = HeapRequestBodyBuffer.init(), - }; - } - - return .{ .heap = bun.take(&this.lazy_request_body_buffer).? }; - } - return .{ - .stack = std.heap.stackFallback(request_body_send_stack_buffer_size, bun.default_allocator), - }; - } - - pub fn deflater(this: *@This()) *LibdeflateState { - if (this.lazy_libdeflater == null) { - this.lazy_libdeflater = LibdeflateState.new(.{ - .decompressor = bun.libdeflate.Decompressor.alloc() orelse bun.outOfMemory(), - }); - } - - return this.lazy_libdeflater.?; - } - - fn onInitErrorNoop(err: InitError, opts: InitOpts) noreturn { - switch (err) { - error.LoadCAFile => { - if (!bun.sys.existsZ(opts.abs_ca_file_name)) { - Output.err("HTTPThread", "failed to find CA file: '{s}'", .{opts.abs_ca_file_name}); - } else { - Output.err("HTTPThread", "failed to load CA file: '{s}'", .{opts.abs_ca_file_name}); - } - }, - error.InvalidCAFile => { - Output.err("HTTPThread", "the CA file is invalid: '{s}'", .{opts.abs_ca_file_name}); - }, - error.InvalidCA => { - Output.err("HTTPThread", "the provided CA is invalid", .{}); - }, - error.FailedToOpenSocket => { - Output.errGeneric("failed to start HTTP client thread", .{}); - }, - } - Global.crash(); - } - - pub const InitOpts = struct { - ca: []stringZ = &.{}, - abs_ca_file_name: stringZ = &.{}, - for_install: bool = false, - - onInitError: *const fn (err: InitError, opts: InitOpts) noreturn = &onInitErrorNoop, - }; - - fn initOnce(opts: *const InitOpts) void { - http_thread = .{ - .loop = undefined, - .http_context = .{ - .us_socket_context = undefined, - .pending_sockets = NewHTTPContext(false).PooledSocketHiveAllocator.empty, - }, - .https_context = .{ - .us_socket_context = undefined, - .pending_sockets = NewHTTPContext(true).PooledSocketHiveAllocator.empty, - }, - .timer = std.time.Timer.start() catch unreachable, - }; - bun.libdeflate.load(); - const thread = std.Thread.spawn( - .{ - .stack_size = bun.default_thread_stack_size, - }, - onStart, - .{opts.*}, - ) catch |err| Output.panic("Failed to start HTTP Client thread: {s}", .{@errorName(err)}); - thread.detach(); - } - var init_once = bun.once(initOnce); - - pub fn init(opts: *const InitOpts) void { - init_once.call(.{opts}); - } - - pub fn onStart(opts: InitOpts) void { - Output.Source.configureNamedThread("HTTP Client"); - default_arena = Arena.init() catch unreachable; - default_allocator = default_arena.allocator(); - - const loop = bun.JSC.MiniEventLoop.initGlobal(null); - - if (Environment.isWindows) { - _ = std.process.getenvW(comptime bun.strings.w("SystemRoot")) orelse { - bun.Output.errGeneric("The %SystemRoot% environment variable is not set. Bun needs this set in order for network requests to work.", .{}); - Global.crash(); - }; - } - - http_thread.loop = loop; - http_thread.http_context.init(); - http_thread.https_context.initWithThreadOpts(&opts) catch |err| opts.onInitError(err, opts); - http_thread.has_awoken.store(true, .monotonic); - http_thread.processEvents(); - } - - pub fn connect(this: *@This(), client: *HTTPClient, comptime is_ssl: bool) !NewHTTPContext(is_ssl).HTTPSocket { - if (client.unix_socket_path.length() > 0) { - return try this.context(is_ssl).connectSocket(client, client.unix_socket_path.slice()); - } - - if (comptime is_ssl) { - const needs_own_context = client.tls_props != null and client.tls_props.?.requires_custom_request_ctx; - if (needs_own_context) { - var requested_config = client.tls_props.?; - for (custom_ssl_context_map.keys()) |other_config| { - if (requested_config.isSame(other_config)) { - // we free the callers config since we have a existing one - if (requested_config != client.tls_props) { - requested_config.deinit(); - bun.default_allocator.destroy(requested_config); - } - client.tls_props = other_config; - if (client.http_proxy) |url| { - return try custom_ssl_context_map.get(other_config).?.connect(client, url.hostname, url.getPortAuto()); - } else { - return try custom_ssl_context_map.get(other_config).?.connect(client, client.url.hostname, client.url.getPortAuto()); - } - } - } - // we need the config so dont free it - var custom_context = try bun.default_allocator.create(NewHTTPContext(is_ssl)); - custom_context.initWithClientConfig(client) catch |err| { - client.tls_props = null; - - requested_config.deinit(); - bun.default_allocator.destroy(requested_config); - bun.default_allocator.destroy(custom_context); - - // TODO: these error names reach js. figure out how they should be handled - return switch (err) { - error.FailedToOpenSocket => |e| e, - error.InvalidCA => error.FailedToOpenSocket, - error.InvalidCAFile => error.FailedToOpenSocket, - error.LoadCAFile => error.FailedToOpenSocket, - }; - }; - try custom_ssl_context_map.put(requested_config, custom_context); - // We might deinit the socket context, so we disable keepalive to make sure we don't - // free it while in use. - client.flags.disable_keepalive = true; - if (client.http_proxy) |url| { - // https://github.com/oven-sh/bun/issues/11343 - if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http")) { - return try this.context(is_ssl).connect(client, url.hostname, url.getPortAuto()); - } - return error.UnsupportedProxyProtocol; - } - return try custom_context.connect(client, client.url.hostname, client.url.getPortAuto()); - } - } - if (client.http_proxy) |url| { - if (url.href.len > 0) { - // https://github.com/oven-sh/bun/issues/11343 - if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http")) { - return try this.context(is_ssl).connect(client, url.hostname, url.getPortAuto()); - } - return error.UnsupportedProxyProtocol; - } - } - return try this.context(is_ssl).connect(client, client.url.hostname, client.url.getPortAuto()); - } - - pub fn context(this: *@This(), comptime is_ssl: bool) *NewHTTPContext(is_ssl) { - return if (is_ssl) &this.https_context else &this.http_context; - } - - fn drainEvents(this: *@This()) void { - { - this.queued_shutdowns_lock.lock(); - defer this.queued_shutdowns_lock.unlock(); - for (this.queued_shutdowns.items) |http| { - if (socket_async_http_abort_tracker.fetchSwapRemove(http.async_http_id)) |socket_ptr| { - if (http.is_tls) { - const socket = uws.SocketTLS.fromAny(socket_ptr.value); - // do a fast shutdown here since we are aborting and we dont want to wait for the close_notify from the other side - socket.close(.failure); - } else { - const socket = uws.SocketTCP.fromAny(socket_ptr.value); - socket.close(.failure); - } - } - } - this.queued_shutdowns.clearRetainingCapacity(); - } - { - this.queued_writes_lock.lock(); - defer this.queued_writes_lock.unlock(); - for (this.queued_writes.items) |write| { - const ended = write.flags.ended; - defer if (!strings.eqlComptime(write.data, end_of_chunked_http1_1_encoding_response_body) and write.data.len > 0) { - // "0\r\n\r\n" is always a static so no need to free - bun.default_allocator.free(write.data); - }; - if (socket_async_http_abort_tracker.get(write.async_http_id)) |socket_ptr| { - if (write.flags.is_tls) { - const socket = uws.SocketTLS.fromAny(socket_ptr); - if (socket.isClosed() or socket.isShutdown()) { - continue; - } - const tagged = NewHTTPContext(true).getTaggedFromSocket(socket); - if (tagged.get(HTTPClient)) |client| { - if (client.state.original_request_body == .stream) { - var stream = &client.state.original_request_body.stream; - if (write.data.len > 0) { - stream.buffer.write(write.data) catch {}; - } - stream.ended = ended; - if (!stream.has_backpressure) { - client.onWritable( - false, - true, - socket, - ); - } - } - } - } else { - const socket = uws.SocketTCP.fromAny(socket_ptr); - if (socket.isClosed() or socket.isShutdown()) { - continue; - } - const tagged = NewHTTPContext(false).getTaggedFromSocket(socket); - if (tagged.get(HTTPClient)) |client| { - if (client.state.original_request_body == .stream) { - var stream = &client.state.original_request_body.stream; - if (write.data.len > 0) { - stream.buffer.write(write.data) catch {}; - } - stream.ended = ended; - if (!stream.has_backpressure) { - client.onWritable( - false, - false, - socket, - ); - } - } - } - } - } - } - this.queued_writes.clearRetainingCapacity(); - } - - while (this.queued_proxy_deref.pop()) |http| { - http.deref(); - } - - var count: usize = 0; - var active = AsyncHTTP.active_requests_count.load(.monotonic); - const max = AsyncHTTP.max_simultaneous_requests.load(.monotonic); - if (active >= max) return; - defer { - if (comptime Environment.allow_assert) { - if (count > 0) - log("Processed {d} tasks\n", .{count}); - } - } - - while (this.queued_tasks.pop()) |http| { - var cloned = ThreadlocalAsyncHTTP.new(.{ - .async_http = http.*, - }); - cloned.async_http.real = http; - cloned.async_http.onStart(); - if (comptime Environment.allow_assert) { - count += 1; - } - - active += 1; - if (active >= max) break; - } - } - - fn processEvents(this: *@This()) noreturn { - if (comptime Environment.isPosix) { - this.loop.loop.num_polls = @max(2, this.loop.loop.num_polls); - } else if (comptime Environment.isWindows) { - this.loop.loop.inc(); - } else { - @compileError("TODO:"); - } - - while (true) { - this.drainEvents(); - - var start_time: i128 = 0; - if (comptime Environment.isDebug) { - start_time = std.time.nanoTimestamp(); - } - Output.flush(); - - this.loop.loop.inc(); - this.loop.loop.tick(); - this.loop.loop.dec(); - - // this.loop.run(); - if (comptime Environment.isDebug) { - const end = std.time.nanoTimestamp(); - threadlog("Waited {any}\n", .{std.fmt.fmtDurationSigned(@as(i64, @truncate(end - start_time)))}); - Output.flush(); - } - } - } - - pub fn scheduleShutdown(this: *@This(), http: *AsyncHTTP) void { - { - this.queued_shutdowns_lock.lock(); - defer this.queued_shutdowns_lock.unlock(); - this.queued_shutdowns.append(bun.default_allocator, .{ - .async_http_id = http.async_http_id, - .is_tls = http.client.isHTTPS(), - }) catch bun.outOfMemory(); - } - if (this.has_awoken.load(.monotonic)) - this.loop.loop.wakeup(); - } - - pub fn scheduleRequestWrite(this: *@This(), http: *AsyncHTTP, data: []const u8, ended: bool) void { - { - this.queued_writes_lock.lock(); - defer this.queued_writes_lock.unlock(); - this.queued_writes.append(bun.default_allocator, .{ - .async_http_id = http.async_http_id, - .data = data, - .flags = .{ - .is_tls = http.client.isHTTPS(), - .ended = ended, - }, - }) catch bun.outOfMemory(); - } - if (this.has_awoken.load(.monotonic)) - this.loop.loop.wakeup(); - } - - pub fn scheduleProxyDeref(this: *@This(), proxy: *ProxyTunnel) void { - // this is always called on the http thread - { - this.queued_proxy_deref.append(bun.default_allocator, proxy) catch bun.outOfMemory(); - } - if (this.has_awoken.load(.monotonic)) - this.loop.loop.wakeup(); - } - - pub fn wakeup(this: *@This()) void { - if (this.has_awoken.load(.monotonic)) - this.loop.loop.wakeup(); - } - - pub fn schedule(this: *@This(), batch: Batch) void { - if (batch.len == 0) - return; - - { - var batch_ = batch; - while (batch_.pop()) |task| { - const http: *AsyncHTTP = @fieldParentPtr("task", task); - this.queued_tasks.push(http); - } - } - - if (this.has_awoken.load(.monotonic)) - this.loop.loop.wakeup(); - } -}; - const log = Output.scoped(.fetch, false); -var temp_hostname: [8192]u8 = undefined; +pub var temp_hostname: [8192]u8 = undefined; pub fn checkServerIdentity( client: *HTTPClient, @@ -1612,7 +97,7 @@ pub fn checkServerIdentity( return true; } -fn registerAbortTracker( +pub fn registerAbortTracker( client: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket, @@ -1622,7 +107,7 @@ fn registerAbortTracker( } } -fn unregisterAbortTracker( +pub fn unregisterAbortTracker( client: *HTTPClient, ) void { if (client.signals.aborted != null) { @@ -1896,363 +381,6 @@ fn writeRequest( _ = writer.write("\r\n") catch 0; } -pub const HTTPStage = enum { - pending, - headers, - body, - body_chunk, - fail, - done, - proxy_handshake, - proxy_headers, - proxy_body, -}; - -pub const CertificateInfo = struct { - cert: []const u8, - cert_error: HTTPCertError, - hostname: []const u8, - pub fn deinit(this: *const CertificateInfo, allocator: std.mem.Allocator) void { - allocator.free(this.cert); - allocator.free(this.cert_error.code); - allocator.free(this.cert_error.reason); - allocator.free(this.hostname); - } -}; - -const Decompressor = union(enum) { - zlib: *Zlib.ZlibReaderArrayList, - brotli: *Brotli.BrotliReaderArrayList, - zstd: *zstd.ZstdReaderArrayList, - none: void, - - pub fn deinit(this: *Decompressor) void { - switch (this.*) { - inline .brotli, .zlib, .zstd => |that| { - that.deinit(); - this.* = .{ .none = {} }; - }, - .none => {}, - } - } - - pub fn updateBuffers(this: *Decompressor, encoding: Encoding, buffer: []const u8, body_out_str: *MutableString) !void { - if (!encoding.isCompressed()) { - return; - } - - if (this.* == .none) { - switch (encoding) { - .gzip, .deflate => { - this.* = .{ - .zlib = try Zlib.ZlibReaderArrayList.initWithOptionsAndListAllocator( - buffer, - &body_out_str.list, - body_out_str.allocator, - default_allocator, - .{ - // zlib.MAX_WBITS = 15 - // to (de-)compress deflate format, use wbits = -zlib.MAX_WBITS - // to (de-)compress deflate format with headers we use wbits = 0 (we can detect the first byte using 120) - // to (de-)compress gzip format, use wbits = zlib.MAX_WBITS | 16 - .windowBits = if (encoding == Encoding.gzip) Zlib.MAX_WBITS | 16 else (if (buffer.len > 1 and buffer[0] == 120) 0 else -Zlib.MAX_WBITS), - }, - ), - }; - return; - }, - .brotli => { - this.* = .{ - .brotli = try Brotli.BrotliReaderArrayList.newWithOptions( - buffer, - &body_out_str.list, - body_out_str.allocator, - .{}, - ), - }; - return; - }, - .zstd => { - this.* = .{ - .zstd = try zstd.ZstdReaderArrayList.initWithListAllocator( - buffer, - &body_out_str.list, - body_out_str.allocator, - default_allocator, - ), - }; - return; - }, - else => @panic("Invalid encoding. This code should not be reachable"), - } - } - - switch (this.*) { - .zlib => |reader| { - assert(reader.zlib.avail_in == 0); - reader.zlib.next_in = buffer.ptr; - reader.zlib.avail_in = @as(u32, @truncate(buffer.len)); - - const initial = body_out_str.list.items.len; - body_out_str.list.expandToCapacity(); - if (body_out_str.list.capacity == initial) { - try body_out_str.list.ensureUnusedCapacity(body_out_str.allocator, 4096); - body_out_str.list.expandToCapacity(); - } - reader.list = body_out_str.list; - reader.zlib.next_out = @ptrCast(&body_out_str.list.items[initial]); - reader.zlib.avail_out = @as(u32, @truncate(body_out_str.list.capacity - initial)); - // we reset the total out so we can track how much we decompressed this time - reader.zlib.total_out = @truncate(initial); - }, - .brotli => |reader| { - reader.input = buffer; - reader.total_in = 0; - - const initial = body_out_str.list.items.len; - reader.list = body_out_str.list; - reader.total_out = @truncate(initial); - }, - .zstd => |reader| { - reader.input = buffer; - reader.total_in = 0; - - const initial = body_out_str.list.items.len; - reader.list = body_out_str.list; - reader.total_out = @truncate(initial); - }, - else => @panic("Invalid encoding. This code should not be reachable"), - } - } - - pub fn readAll(this: *Decompressor, is_done: bool) !void { - switch (this.*) { - .zlib => |zlib| try zlib.readAll(), - .brotli => |brotli| try brotli.readAll(is_done), - .zstd => |reader| try reader.readAll(is_done), - .none => {}, - } - } -}; - -// TODO: reduce the size of this struct -// Many of these fields can be moved to a packed struct and use less space -pub const InternalState = struct { - response_message_buffer: MutableString = undefined, - /// pending response is the temporary storage for the response headers, url and status code - /// this uses shared_response_headers_buf to store the headers - /// this will be turned null once the metadata is cloned - pending_response: ?picohttp.Response = null, - - /// This is the cloned metadata containing the response headers, url and status code after the .headers phase are received - /// will be turned null once returned to the user (the ownership is transferred to the user) - /// this can happen after await fetch(...) and the body can continue streaming when this is already null - /// the user will receive only chunks of the body stored in body_out_str - cloned_metadata: ?HTTPResponseMetadata = null, - flags: InternalStateFlags = InternalStateFlags{}, - - transfer_encoding: Encoding = Encoding.identity, - encoding: Encoding = Encoding.identity, - content_encoding_i: u8 = std.math.maxInt(u8), - chunked_decoder: picohttp.phr_chunked_decoder = .{}, - decompressor: Decompressor = .{ .none = {} }, - stage: Stage = Stage.pending, - /// This is owned by the user and should not be freed here - body_out_str: ?*MutableString = null, - compressed_body: MutableString = undefined, - content_length: ?usize = null, - total_body_received: usize = 0, - request_body: []const u8 = "", - original_request_body: HTTPRequestBody = .{ .bytes = "" }, - request_sent_len: usize = 0, - fail: ?anyerror = null, - request_stage: HTTPStage = .pending, - response_stage: HTTPStage = .pending, - certificate_info: ?CertificateInfo = null, - - pub const InternalStateFlags = packed struct(u8) { - allow_keepalive: bool = true, - received_last_chunk: bool = false, - did_set_content_encoding: bool = false, - is_redirect_pending: bool = false, - is_libdeflate_fast_path_disabled: bool = false, - resend_request_body_on_redirect: bool = false, - _padding: u2 = 0, - }; - - pub fn init(body: HTTPRequestBody, body_out_str: *MutableString) InternalState { - return .{ - .original_request_body = body, - .request_body = if (body == .bytes) body.bytes else "", - .compressed_body = MutableString{ .allocator = default_allocator, .list = .{} }, - .response_message_buffer = MutableString{ .allocator = default_allocator, .list = .{} }, - .body_out_str = body_out_str, - .stage = Stage.pending, - .pending_response = null, - }; - } - - pub fn isChunkedEncoding(this: *InternalState) bool { - return this.transfer_encoding == Encoding.chunked; - } - - pub fn reset(this: *InternalState, allocator: std.mem.Allocator) void { - this.compressed_body.deinit(); - this.response_message_buffer.deinit(); - - const body_msg = this.body_out_str; - if (body_msg) |body| body.reset(); - this.decompressor.deinit(); - - // just in case we check and free to avoid leaks - if (this.cloned_metadata != null) { - this.cloned_metadata.?.deinit(allocator); - this.cloned_metadata = null; - } - - // if exists we own this info - if (this.certificate_info) |info| { - this.certificate_info = null; - info.deinit(bun.default_allocator); - } - - this.original_request_body.deinit(); - this.* = .{ - .body_out_str = body_msg, - .compressed_body = MutableString{ .allocator = default_allocator, .list = .{} }, - .response_message_buffer = MutableString{ .allocator = default_allocator, .list = .{} }, - .original_request_body = .{ .bytes = "" }, - .request_body = "", - .certificate_info = null, - .flags = .{}, - .total_body_received = 0, - }; - } - - pub fn getBodyBuffer(this: *InternalState) *MutableString { - if (this.encoding.isCompressed()) { - return &this.compressed_body; - } - - return this.body_out_str.?; - } - - fn isDone(this: *InternalState) bool { - if (this.isChunkedEncoding()) { - return this.flags.received_last_chunk; - } - - if (this.content_length) |content_length| { - return this.total_body_received >= content_length; - } - - // Content-Type: text/event-stream we should be done only when Close/End/Timeout connection - return this.flags.received_last_chunk; - } - - fn decompressBytes(this: *InternalState, buffer: []const u8, body_out_str: *MutableString, is_final_chunk: bool) !void { - defer this.compressed_body.reset(); - var gzip_timer: std.time.Timer = undefined; - - if (extremely_verbose) - gzip_timer = std.time.Timer.start() catch @panic("Timer failure"); - - var still_needs_to_decompress = true; - - if (FeatureFlags.isLibdeflateEnabled()) { - // Fast-path: use libdeflate - if (is_final_chunk and !this.flags.is_libdeflate_fast_path_disabled and this.encoding.canUseLibDeflate() and this.isDone()) libdeflate: { - this.flags.is_libdeflate_fast_path_disabled = true; - - log("Decompressing {d} bytes with libdeflate\n", .{buffer.len}); - var deflater = http_thread.deflater(); - - // gzip stores the size of the uncompressed data in the last 4 bytes of the stream - // But it's only valid if the stream is less than 4.7 GB, since it's 4 bytes. - // If we know that the stream is going to be larger than our - // pre-allocated buffer, then let's dynamically allocate the exact - // size. - if (this.encoding == Encoding.gzip and buffer.len > 16 and buffer.len < 1024 * 1024 * 1024) { - const estimated_size: u32 = @bitCast(buffer[buffer.len - 4 ..][0..4].*); - // Since this is arbtirary input from the internet, let's set an upper bound of 32 MB for the allocation size. - if (estimated_size > deflater.shared_buffer.len and estimated_size < 32 * 1024 * 1024) { - try body_out_str.list.ensureTotalCapacityPrecise(body_out_str.allocator, estimated_size); - const result = deflater.decompressor.decompress(buffer, body_out_str.list.allocatedSlice(), .gzip); - - if (result.status == .success) { - body_out_str.list.items.len = result.written; - still_needs_to_decompress = false; - } - - break :libdeflate; - } - } - - const result = deflater.decompressor.decompress(buffer, &deflater.shared_buffer, switch (this.encoding) { - .gzip => .gzip, - .deflate => .deflate, - else => unreachable, - }); - - if (result.status == .success) { - try body_out_str.list.ensureTotalCapacityPrecise(body_out_str.allocator, result.written); - body_out_str.list.appendSliceAssumeCapacity(deflater.shared_buffer[0..result.written]); - still_needs_to_decompress = false; - } - } - } - - // Slow path, or brotli: use the .decompressor - if (still_needs_to_decompress) { - log("Decompressing {d} bytes\n", .{buffer.len}); - if (body_out_str.list.capacity == 0) { - const min = @min(@ceil(@as(f64, @floatFromInt(buffer.len)) * 1.5), @as(f64, 1024 * 1024 * 2)); - try body_out_str.growBy(@max(@as(usize, @intFromFloat(min)), 32)); - } - - try this.decompressor.updateBuffers(this.encoding, buffer, body_out_str); - - this.decompressor.readAll(this.isDone()) catch |err| { - if (this.isDone() or error.ShortRead != err) { - Output.prettyErrorln("Decompression error: {s}", .{bun.asByteSlice(@errorName(err))}); - Output.flush(); - return err; - } - }; - } - - if (extremely_verbose) - this.gzip_elapsed = gzip_timer.read(); - } - - fn decompress(this: *InternalState, buffer: MutableString, body_out_str: *MutableString, is_final_chunk: bool) !void { - try this.decompressBytes(buffer.list.items, body_out_str, is_final_chunk); - } - - pub fn processBodyBuffer(this: *InternalState, buffer: MutableString, is_final_chunk: bool) !bool { - if (this.flags.is_redirect_pending) return false; - - var body_out_str = this.body_out_str.?; - - switch (this.encoding) { - Encoding.brotli, Encoding.gzip, Encoding.deflate, Encoding.zstd => { - try this.decompress(buffer, body_out_str, is_final_chunk); - }, - else => { - if (!body_out_str.owns(buffer.list.items)) { - body_out_str.append(buffer.list.items) catch |err| { - Output.prettyErrorln("Failed to append to body buffer: {s}", .{bun.asByteSlice(@errorName(err))}); - Output.flush(); - return err; - }; - } - }, - } - - return this.body_out_str.?.list.items.len > 0; - } -}; - const default_redirect_count = 127; pub const HTTPVerboseLevel = enum { @@ -2343,13 +471,6 @@ pub fn isKeepAlivePossible(this: *HTTPClient) bool { return false; } -const Stage = enum(u8) { - pending, - connect, - done, - fail, -}; - // lowercase hash header names so that we can be sure pub fn hashHeaderName(name: string) u64 { var hasher = std.hash.Wyhash.init(0); @@ -2386,29 +507,6 @@ const authorization_header_hash = hashHeaderConst("Authorization"); const proxy_authorization_header_hash = hashHeaderConst("Proxy-Authorization"); const cookie_header_hash = hashHeaderConst("Cookie"); -pub const Encoding = enum { - identity, - gzip, - deflate, - brotli, - zstd, - chunked, - - pub fn canUseLibDeflate(this: Encoding) bool { - return switch (this) { - .gzip, .deflate => true, - else => false, - }; - } - - pub fn isCompressed(this: Encoding) bool { - return switch (this) { - .brotli, .gzip, .deflate, .zstd => true, - else => false, - }; - } -}; - const host_header_name = "Host"; const content_length_header_name = "Content-Length"; const chunked_encoded_header = picohttp.Header{ .name = "Transfer-Encoding", .value = "chunked" }; @@ -2432,499 +530,7 @@ pub fn headerStr(this: *const HTTPClient, ptr: Api.StringPointer) string { return this.header_buf[ptr.offset..][0..ptr.length]; } -pub const HeaderBuilder = @import("./http/header_builder.zig"); - -const HTTPCallbackPair = .{ *AsyncHTTP, HTTPClientResult }; -pub const HTTPChannel = @import("./sync.zig").Channel(HTTPCallbackPair, .{ .Static = 1000 }); -// 32 pointers much cheaper than 1000 pointers -const SingleHTTPChannel = struct { - const SingleHTTPCHannel_ = @import("./sync.zig").Channel(HTTPClientResult, .{ .Static = 8 }); - channel: SingleHTTPCHannel_, - pub fn reset(_: *@This()) void {} - pub fn init() SingleHTTPChannel { - return SingleHTTPChannel{ .channel = SingleHTTPCHannel_.init() }; - } -}; - -pub const HTTPChannelContext = struct { - http: AsyncHTTP = undefined, - channel: *HTTPChannel, - - pub fn callback(data: HTTPCallbackPair) void { - var this: *HTTPChannelContext = @fieldParentPtr("http", data.@"0"); - this.channel.writeItem(data) catch unreachable; - } -}; - -pub const AsyncHTTP = struct { - request: ?picohttp.Request = null, - response: ?picohttp.Response = null, - request_headers: Headers.Entry.List = .empty, - response_headers: Headers.Entry.List = .empty, - response_buffer: *MutableString, - request_body: HTTPRequestBody = .{ .bytes = "" }, - allocator: std.mem.Allocator, - request_header_buf: string = "", - method: Method = Method.GET, - url: URL, - http_proxy: ?URL = null, - real: ?*AsyncHTTP = null, - next: ?*AsyncHTTP = null, - - task: ThreadPool.Task = ThreadPool.Task{ .callback = &startAsyncHTTP }, - result_callback: HTTPClientResult.Callback = undefined, - - redirected: bool = false, - - response_encoding: Encoding = Encoding.identity, - verbose: HTTPVerboseLevel = .none, - - client: HTTPClient = undefined, - waitingDeffered: bool = false, - finalized: bool = false, - err: ?anyerror = null, - async_http_id: u32 = 0, - - state: AtomicState = AtomicState.init(State.pending), - elapsed: u64 = 0, - gzip_elapsed: u64 = 0, - - signals: Signals = .{}, - - pub var active_requests_count = std.atomic.Value(usize).init(0); - pub var max_simultaneous_requests = std.atomic.Value(usize).init(256); - - pub fn loadEnv(allocator: std.mem.Allocator, logger: *Log, env: *DotEnv.Loader) void { - if (env.get("BUN_CONFIG_MAX_HTTP_REQUESTS")) |max_http_requests| { - const max = std.fmt.parseInt(u16, max_http_requests, 10) catch { - logger.addErrorFmt( - null, - Loc.Empty, - allocator, - "BUN_CONFIG_MAX_HTTP_REQUESTS value \"{s}\" is not a valid integer between 1 and 65535", - .{max_http_requests}, - ) catch unreachable; - return; - }; - if (max == 0) { - logger.addWarningFmt( - null, - Loc.Empty, - allocator, - "BUN_CONFIG_MAX_HTTP_REQUESTS value must be a number between 1 and 65535", - .{}, - ) catch unreachable; - return; - } - AsyncHTTP.max_simultaneous_requests.store(max, .monotonic); - } - } - - pub fn signalHeaderProgress(this: *AsyncHTTP) void { - var progress = this.signals.header_progress orelse return; - progress.store(true, .release); - } - - pub fn enableBodyStreaming(this: *AsyncHTTP) void { - var stream = this.signals.body_streaming orelse return; - stream.store(true, .release); - } - - pub fn clearData(this: *AsyncHTTP) void { - this.response_headers.deinit(this.allocator); - this.response_headers = .{}; - this.request = null; - this.response = null; - this.client.unix_socket_path.deinit(); - this.client.unix_socket_path = JSC.ZigString.Slice.empty; - } - - pub const State = enum(u32) { - pending = 0, - scheduled = 1, - sending = 2, - success = 3, - fail = 4, - }; - const AtomicState = std.atomic.Value(State); - - pub const Options = struct { - http_proxy: ?URL = null, - hostname: ?[]u8 = null, - signals: ?Signals = null, - unix_socket_path: ?JSC.ZigString.Slice = null, - disable_timeout: ?bool = null, - verbose: ?HTTPVerboseLevel = null, - disable_keepalive: ?bool = null, - disable_decompression: ?bool = null, - reject_unauthorized: ?bool = null, - tls_props: ?*SSLConfig = null, - }; - - const Preconnect = struct { - async_http: AsyncHTTP, - response_buffer: MutableString, - url: bun.URL, - is_url_owned: bool, - - pub const new = bun.TrivialNew(@This()); - - pub fn onResult(this: *Preconnect, _: *AsyncHTTP, _: HTTPClientResult) void { - this.response_buffer.deinit(); - this.async_http.clearData(); - this.async_http.client.deinit(); - if (this.is_url_owned) { - bun.default_allocator.free(this.url.href); - } - - bun.destroy(this); - } - }; - - pub fn preconnect( - url: URL, - is_url_owned: bool, - ) void { - if (!FeatureFlags.is_fetch_preconnect_supported) { - if (is_url_owned) { - bun.default_allocator.free(url.href); - } - - return; - } - - var this = Preconnect.new(.{ - .async_http = undefined, - .response_buffer = MutableString{ .allocator = default_allocator, .list = .{} }, - .url = url, - .is_url_owned = is_url_owned, - }); - - this.async_http = AsyncHTTP.init(bun.default_allocator, .GET, url, .{}, "", &this.response_buffer, "", HTTPClientResult.Callback.New(*Preconnect, Preconnect.onResult).init(this), .manual, .{}); - this.async_http.client.flags.is_preconnect_only = true; - - http_thread.schedule(Batch.from(&this.async_http.task)); - } - - pub fn init( - allocator: std.mem.Allocator, - method: Method, - url: URL, - headers: Headers.Entry.List, - headers_buf: string, - response_buffer: *MutableString, - request_body: []const u8, - callback: HTTPClientResult.Callback, - redirect_type: FetchRedirect, - options: Options, - ) AsyncHTTP { - var this = AsyncHTTP{ - .allocator = allocator, - .url = url, - .method = method, - .request_headers = headers, - .request_header_buf = headers_buf, - .request_body = .{ .bytes = request_body }, - .response_buffer = response_buffer, - .result_callback = callback, - .http_proxy = options.http_proxy, - .signals = options.signals orelse .{}, - .async_http_id = if (options.signals != null and options.signals.?.aborted != null) async_http_id_monotonic.fetchAdd(1, .monotonic) else 0, - }; - - this.client = .{ - .allocator = allocator, - .method = method, - .url = url, - .header_entries = headers, - .header_buf = headers_buf, - .hostname = options.hostname, - .signals = options.signals orelse this.signals, - .async_http_id = this.async_http_id, - .http_proxy = this.http_proxy, - .redirect_type = redirect_type, - }; - if (options.unix_socket_path) |val| { - assert(this.client.unix_socket_path.length() == 0); - this.client.unix_socket_path = val; - } - if (options.disable_timeout) |val| { - this.client.flags.disable_timeout = val; - } - if (options.verbose) |val| { - this.client.verbose = val; - } - if (options.disable_decompression) |val| { - this.client.flags.disable_decompression = val; - } - if (options.disable_keepalive) |val| { - this.client.flags.disable_keepalive = val; - } - if (options.reject_unauthorized) |val| { - this.client.flags.reject_unauthorized = val; - } - if (options.tls_props) |val| { - this.client.tls_props = val; - } - - if (options.http_proxy) |proxy| { - // Username between 0 and 4096 chars - if (proxy.username.len > 0 and proxy.username.len < 4096) { - // Password between 0 and 4096 chars - if (proxy.password.len > 0 and proxy.password.len < 4096) { - // decode password - var password_buffer = std.mem.zeroes([4096]u8); - var password_stream = std.io.fixedBufferStream(&password_buffer); - const password_writer = password_stream.writer(); - const PassWriter = @TypeOf(password_writer); - const password_len = PercentEncoding.decode(PassWriter, password_writer, proxy.password) catch { - // Invalid proxy authorization - return this; - }; - const password = password_buffer[0..password_len]; - - // Decode username - var username_buffer = std.mem.zeroes([4096]u8); - var username_stream = std.io.fixedBufferStream(&username_buffer); - const username_writer = username_stream.writer(); - const UserWriter = @TypeOf(username_writer); - const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { - // Invalid proxy authorization - return this; - }; - const username = username_buffer[0..username_len]; - - // concat user and password - const auth = std.fmt.allocPrint(allocator, "{s}:{s}", .{ username, password }) catch unreachable; - defer allocator.free(auth); - const size = std.base64.standard.Encoder.calcSize(auth.len); - var buf = this.allocator.alloc(u8, size + "Basic ".len) catch unreachable; - const encoded = std.base64.url_safe.Encoder.encode(buf["Basic ".len..], auth); - buf[0.."Basic ".len].* = "Basic ".*; - this.client.proxy_authorization = buf[0 .. "Basic ".len + encoded.len]; - } else { - //Decode username - var username_buffer = std.mem.zeroes([4096]u8); - var username_stream = std.io.fixedBufferStream(&username_buffer); - const username_writer = username_stream.writer(); - const UserWriter = @TypeOf(username_writer); - const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { - // Invalid proxy authorization - return this; - }; - const username = username_buffer[0..username_len]; - - // only use user - const size = std.base64.standard.Encoder.calcSize(username_len); - var buf = allocator.alloc(u8, size + "Basic ".len) catch unreachable; - const encoded = std.base64.url_safe.Encoder.encode(buf["Basic ".len..], username); - buf[0.."Basic ".len].* = "Basic ".*; - this.client.proxy_authorization = buf[0 .. "Basic ".len + encoded.len]; - } - } - } - return this; - } - - pub fn initSync( - allocator: std.mem.Allocator, - method: Method, - url: URL, - headers: Headers.Entry.List, - headers_buf: string, - response_buffer: *MutableString, - request_body: []const u8, - http_proxy: ?URL, - hostname: ?[]u8, - redirect_type: FetchRedirect, - ) AsyncHTTP { - return @This().init( - allocator, - method, - url, - headers, - headers_buf, - response_buffer, - request_body, - undefined, - redirect_type, - .{ - .http_proxy = http_proxy, - .hostname = hostname, - }, - ); - } - - fn reset(this: *AsyncHTTP) !void { - const aborted = this.client.aborted; - this.client = try HTTPClient.init(this.allocator, this.method, this.client.url, this.client.header_entries, this.client.header_buf, aborted); - this.client.http_proxy = this.http_proxy; - - if (this.http_proxy) |proxy| { - //TODO: need to understand how is possible to reuse Proxy with TSL, so disable keepalive if url is HTTPS - this.client.flags.disable_keepalive = this.url.isHTTPS(); - // Username between 0 and 4096 chars - if (proxy.username.len > 0 and proxy.username.len < 4096) { - // Password between 0 and 4096 chars - if (proxy.password.len > 0 and proxy.password.len < 4096) { - // decode password - var password_buffer = std.mem.zeroes([4096]u8); - var password_stream = std.io.fixedBufferStream(&password_buffer); - const password_writer = password_stream.writer(); - const PassWriter = @TypeOf(password_writer); - const password_len = PercentEncoding.decode(PassWriter, password_writer, proxy.password) catch { - // Invalid proxy authorization - return this; - }; - const password = password_buffer[0..password_len]; - - // Decode username - var username_buffer = std.mem.zeroes([4096]u8); - var username_stream = std.io.fixedBufferStream(&username_buffer); - const username_writer = username_stream.writer(); - const UserWriter = @TypeOf(username_writer); - const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { - // Invalid proxy authorization - return this; - }; - - const username = username_buffer[0..username_len]; - - // concat user and password - const auth = std.fmt.allocPrint(this.allocator, "{s}:{s}", .{ username, password }) catch unreachable; - defer this.allocator.free(auth); - const size = std.base64.standard.Encoder.calcSize(auth.len); - var buf = this.allocator.alloc(u8, size + "Basic ".len) catch unreachable; - const encoded = std.base64.url_safe.Encoder.encode(buf["Basic ".len..], auth); - buf[0.."Basic ".len].* = "Basic ".*; - this.client.proxy_authorization = buf[0 .. "Basic ".len + encoded.len]; - } else { - //Decode username - var username_buffer = std.mem.zeroes([4096]u8); - var username_stream = std.io.fixedBufferStream(&username_buffer); - const username_writer = username_stream.writer(); - const UserWriter = @TypeOf(username_writer); - const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { - // Invalid proxy authorization - return this; - }; - const username = username_buffer[0..username_len]; - - // only use user - const size = std.base64.standard.Encoder.calcSize(username_len); - var buf = this.allocator.alloc(u8, size + "Basic ".len) catch unreachable; - const encoded = std.base64.url_safe.Encoder.encode(buf["Basic ".len..], username); - buf[0.."Basic ".len].* = "Basic ".*; - this.client.proxy_authorization = buf[0 .. "Basic ".len + encoded.len]; - } - } - } - } - - pub fn schedule(this: *AsyncHTTP, _: std.mem.Allocator, batch: *ThreadPool.Batch) void { - this.state.store(.scheduled, .monotonic); - batch.push(ThreadPool.Batch.from(&this.task)); - } - - fn sendSyncCallback(this: *SingleHTTPChannel, async_http: *AsyncHTTP, result: HTTPClientResult) void { - async_http.real.?.* = async_http.*; - async_http.real.?.response_buffer = async_http.response_buffer; - this.channel.writeItem(result) catch unreachable; - } - - pub fn sendSync(this: *AsyncHTTP) anyerror!picohttp.Response { - HTTPThread.init(&.{}); - - var ctx = try bun.default_allocator.create(SingleHTTPChannel); - ctx.* = SingleHTTPChannel.init(); - this.result_callback = HTTPClientResult.Callback.New( - *SingleHTTPChannel, - sendSyncCallback, - ).init(ctx); - - var batch = bun.ThreadPool.Batch{}; - this.schedule(bun.default_allocator, &batch); - http_thread.schedule(batch); - - const result = ctx.channel.readItem() catch unreachable; - if (result.fail) |err| { - return err; - } - assert(result.metadata != null); - return result.metadata.?.response; - } - - pub fn onAsyncHTTPCallback(this: *AsyncHTTP, async_http: *AsyncHTTP, result: HTTPClientResult) void { - assert(this.real != null); - - var callback = this.result_callback; - this.elapsed = http_thread.timer.read() -| this.elapsed; - - // TODO: this condition seems wrong: if we started with a non-default value, we might - // report a redirect even if none happened - this.redirected = this.client.flags.redirected; - if (result.isSuccess()) { - this.err = null; - if (result.metadata) |metadata| { - this.response = metadata.response; - } - this.state.store(.success, .monotonic); - } else { - this.err = result.fail; - this.response = null; - this.state.store(State.fail, .monotonic); - } - - if (comptime Environment.enable_logs) { - if (socket_async_http_abort_tracker.count() > 0) { - log("socket_async_http_abort_tracker count: {d}", .{socket_async_http_abort_tracker.count()}); - } - } - - if (socket_async_http_abort_tracker.capacity() > 10_000 and socket_async_http_abort_tracker.count() < 100) { - socket_async_http_abort_tracker.shrinkAndFree(socket_async_http_abort_tracker.count()); - } - - if (result.has_more) { - callback.function(callback.ctx, async_http, result); - } else { - { - this.client.deinit(); - var threadlocal_http: *ThreadlocalAsyncHTTP = @fieldParentPtr("async_http", async_http); - defer threadlocal_http.deinit(); - log("onAsyncHTTPCallback: {any}", .{std.fmt.fmtDuration(this.elapsed)}); - callback.function(callback.ctx, async_http, result); - } - - const active_requests = AsyncHTTP.active_requests_count.fetchSub(1, .monotonic); - assert(active_requests > 0); - } - - if (!http_thread.queued_tasks.isEmpty() and AsyncHTTP.active_requests_count.load(.monotonic) < AsyncHTTP.max_simultaneous_requests.load(.monotonic)) { - http_thread.loop.loop.wakeup(); - } - } - - pub fn startAsyncHTTP(task: *Task) void { - var this: *AsyncHTTP = @fieldParentPtr("task", task); - this.onStart(); - } - - pub fn onStart(this: *AsyncHTTP) void { - _ = active_requests_count.fetchAdd(1, .monotonic); - this.err = null; - this.state.store(.sending, .monotonic); - this.client.result_callback = HTTPClientResult.Callback.New(*AsyncHTTP, onAsyncHTTPCallback).init( - this, - ); - - this.elapsed = http_thread.timer.read(); - if (this.response_buffer.list.capacity == 0) { - this.response_buffer.allocator = default_allocator; - } - this.client.start(this.request_body, this.response_buffer); - } -}; +pub const HeaderBuilder = @import("./http/HeaderBuilder.zig"); pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request { var header_count: usize = 0; @@ -3192,8 +798,6 @@ fn start_(this: *HTTPClient, comptime is_ssl: bool) void { } } -const Task = ThreadPool.Task; - pub const HTTPResponseMetadata = struct { url: []const u8 = "", owned_buf: []u8 = "", @@ -3316,10 +920,7 @@ noinline fn sendInitialRequestPayload(this: *HTTPClient, comptime is_first_call: assert(!socket.isShutdown()); assert(!socket.isClosed()); } - const amount = socket.write( - to_send, - false, - ); + const amount = try writeToSocket(is_ssl, socket, to_send); if (comptime is_first_call) { if (amount == 0) { // don't worry about it @@ -3331,11 +932,7 @@ noinline fn sendInitialRequestPayload(this: *HTTPClient, comptime is_first_call: } } - if (amount < 0) { - return error.WriteFailed; - } - - this.state.request_sent_len += @as(usize, @intCast(amount)); + this.state.request_sent_len += amount; const has_sent_headers = this.state.request_sent_len >= headers_len; if (has_sent_headers and this.verbose != .none) { @@ -3358,6 +955,102 @@ noinline fn sendInitialRequestPayload(this: *HTTPClient, comptime is_first_call: }; } +pub fn flushStream(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket) void { + // only flush the stream if needed no additional data is being added + this.writeToStream(is_ssl, socket, ""); +} + +/// Write data to the socket (Just a error wrapper to easly handle amount written and error handling) +fn writeToSocket(comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket, data: []const u8) !usize { + const amount = socket.write(data); + if (amount < 0) { + return error.WriteFailed; + } + return @intCast(amount); +} + +/// Write data to the socket and buffer the unwritten data if there is backpressure +fn writeToSocketWithBufferFallback(comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket, buffer: *bun.io.StreamBuffer, data: []const u8) !usize { + const amount = try writeToSocket(is_ssl, socket, data); + if (amount < data.len) { + buffer.write(data[@intCast(amount)..]) catch bun.outOfMemory(); + } + return amount; +} + +/// Write buffered data to the socket returning true if there is backpressure +fn writeToStreamUsingBuffer(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket, buffer: *bun.io.StreamBuffer, data: []const u8) !bool { + if (buffer.isNotEmpty()) { + const to_send = buffer.slice(); + const amount = try writeToSocket(is_ssl, socket, to_send); + this.state.request_sent_len += amount; + buffer.cursor += amount; + if (amount < to_send.len) { + // we could not send all pending data so we need to buffer the extra data + if (data.len > 0) { + buffer.write(data) catch bun.outOfMemory(); + } + // failed to send everything so we have backpressure + return true; + } + if (buffer.isEmpty()) { + buffer.reset(); + } + } + // ok we flushed all pending data so we can reset the backpressure + if (data.len > 0) { + // no backpressure everything was sended so we can just try to send + const sent = try writeToSocketWithBufferFallback(is_ssl, socket, buffer, data); + this.state.request_sent_len += sent; + // if we didn't send all the data we have backpressure + return sent < data.len; + } + // no data to send so we are done + return false; +} + +pub fn writeToStream(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket, data: []const u8) void { + log("flushStream", .{}); + var stream = &this.state.original_request_body.stream; + const stream_buffer = stream.buffer orelse return; + const buffer = stream_buffer.acquire(); + const wasEmpty = buffer.isEmpty() and data.len == 0; + if (wasEmpty and stream.ended) { + // nothing is buffered and the stream is done so we just release and detach + stream_buffer.release(); + stream.detach(); + return; + } + + // to simplify things here the buffer contains the raw data we just need to flush to the socket it + const has_backpressure = writeToStreamUsingBuffer(this, is_ssl, socket, buffer, data) catch |err| { + // we got some critical error so we need to fail and close the connection + stream_buffer.release(); + stream.detach(); + this.closeAndFail(err, is_ssl, socket); + return; + }; + + if (has_backpressure) { + // we have backpressure so just release the buffer and wait for onWritable + stream_buffer.release(); + } else { + if (stream.ended) { + // done sending everything so we can release the buffer and detach the stream + this.state.request_stage = .done; + stream_buffer.release(); + stream.detach(); + } else { + // only report drain if we send everything and previous we had something to send + if (!wasEmpty) { + stream_buffer.reportDrain(); + } + // release the buffer so main thread can use it to send more data + stream_buffer.release(); + } + } +} + pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket) void { if (this.signals.get(.aborted)) { this.closeAndAbort(is_ssl, socket); @@ -3431,14 +1124,13 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s switch (this.state.original_request_body) { .bytes => { const to_send = this.state.request_body; - const amount = socket.write(to_send, true); - if (amount < 0) { - this.closeAndFail(error.WriteFailed, is_ssl, socket); + const sent = writeToSocket(is_ssl, socket, to_send) catch |err| { + this.closeAndFail(err, is_ssl, socket); return; - } + }; - this.state.request_sent_len += @as(usize, @intCast(amount)); - this.state.request_body = this.state.request_body[@as(usize, @intCast(amount))..]; + this.state.request_sent_len += sent; + this.state.request_body = this.state.request_body[sent..]; if (this.state.request_body.len == 0) { this.state.request_stage = .done; @@ -3446,30 +1138,8 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s } }, .stream => { - var stream = &this.state.original_request_body.stream; - stream.has_backpressure = false; - // to simplify things here the buffer contains the raw data we just need to flush to the socket it - if (stream.buffer.isNotEmpty()) { - const to_send = stream.buffer.slice(); - const amount = socket.write(to_send, true); - if (amount < 0) { - this.closeAndFail(error.WriteFailed, is_ssl, socket); - return; - } - this.state.request_sent_len += @as(usize, @intCast(amount)); - stream.buffer.cursor += @intCast(amount); - if (amount < to_send.len) { - stream.has_backpressure = true; - } - if (stream.buffer.isEmpty()) { - stream.buffer.reset(); - } - } - if (stream.hasEnded()) { - this.state.request_stage = .done; - stream.buffer.deinit(); - return; - } + // flush without adding any new data + this.flushStream(is_ssl, socket); }, .sendfile => |*sendfile| { if (comptime is_ssl) { @@ -3500,10 +1170,10 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s this.setTimeout(socket, 5); const to_send = this.state.request_body; - const amount = proxy.writeData(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose + const sent = proxy.writeData(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose - this.state.request_sent_len += @as(usize, @intCast(amount)); - this.state.request_body = this.state.request_body[@as(usize, @intCast(amount))..]; + this.state.request_sent_len += sent; + this.state.request_body = this.state.request_body[sent..]; if (this.state.request_body.len == 0) { this.state.request_stage = .done; @@ -3511,25 +1181,7 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s } }, .stream => { - var stream = &this.state.original_request_body.stream; - stream.has_backpressure = false; - this.setTimeout(socket, 5); - - // to simplify things here the buffer contains the raw data we just need to flush to the socket it - if (stream.buffer.isNotEmpty()) { - const to_send = stream.buffer.slice(); - const amount = proxy.writeData(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose - this.state.request_sent_len += amount; - stream.buffer.cursor += @truncate(amount); - if (amount < to_send.len) { - stream.has_backpressure = true; - } - } - if (stream.hasEnded()) { - this.state.request_stage = .done; - stream.buffer.deinit(); - return; - } + this.flushStream(is_ssl, socket); }, .sendfile => { @panic("sendfile is only supported without SSL. This code should never have been reached!"); @@ -4763,186 +2415,53 @@ pub fn handleResponseMetadata( const assert = bun.assert; // Exists for heap stats reasons. -const ThreadlocalAsyncHTTP = struct { +pub const ThreadlocalAsyncHTTP = struct { pub const new = bun.TrivialNew(@This()); pub const deinit = bun.TrivialDeinit(@This()); async_http: AsyncHTTP, }; -pub const Headers = struct { - pub const Entry = struct { - name: Api.StringPointer, - value: Api.StringPointer, +const bun = @import("bun"); +const picohttp = bun.picohttp; +const JSC = bun.JSC; +const string = bun.string; +const Output = bun.Output; +const Global = bun.Global; +const Environment = bun.Environment; +const strings = bun.strings; +const MutableString = bun.MutableString; +const FeatureFlags = bun.FeatureFlags; - pub const List = bun.MultiArrayList(Entry); - }; +const std = @import("std"); +const URL = @import("./url.zig").URL; - entries: Entry.List = .{}, - buf: std.ArrayListUnmanaged(u8) = .{}, - allocator: std.mem.Allocator, - - pub fn memoryCost(this: *const Headers) usize { - return this.buf.items.len + this.entries.memoryCost(); - } - - pub fn clone(this: *Headers) !Headers { - return Headers{ - .entries = try this.entries.clone(this.allocator), - .buf = try this.buf.clone(this.allocator), - .allocator = this.allocator, - }; - } - - pub fn get(this: *const Headers, name: []const u8) ?[]const u8 { - const entries = this.entries.slice(); - const names = entries.items(.name); - const values = entries.items(.value); - for (names, 0..) |name_ptr, i| { - if (bun.strings.eqlCaseInsensitiveASCII(this.asStr(name_ptr), name, true)) { - return this.asStr(values[i]); - } - } - - return null; - } - - pub fn append(this: *Headers, name: []const u8, value: []const u8) !void { - var offset: u32 = @truncate(this.buf.items.len); - try this.buf.ensureUnusedCapacity(this.allocator, name.len + value.len); - const name_ptr = Api.StringPointer{ - .offset = offset, - .length = @truncate(name.len), - }; - this.buf.appendSliceAssumeCapacity(name); - offset = @truncate(this.buf.items.len); - this.buf.appendSliceAssumeCapacity(value); - - const value_ptr = Api.StringPointer{ - .offset = offset, - .length = @truncate(value.len), - }; - try this.entries.append(this.allocator, .{ - .name = name_ptr, - .value = value_ptr, - }); - } - - pub fn deinit(this: *Headers) void { - this.entries.deinit(this.allocator); - this.buf.clearAndFree(this.allocator); - } - pub fn getContentType(this: *const Headers) ?[]const u8 { - if (this.entries.len == 0 or this.buf.items.len == 0) { - return null; - } - const header_entries = this.entries.slice(); - const header_names = header_entries.items(.name); - const header_values = header_entries.items(.value); - - for (header_names, 0..header_names.len) |name, i| { - if (bun.strings.eqlCaseInsensitiveASCII(this.asStr(name), "content-type", true)) { - return this.asStr(header_values[i]); - } - } - return null; - } - pub fn asStr(this: *const Headers, ptr: Api.StringPointer) []const u8 { - return if (ptr.offset + ptr.length <= this.buf.items.len) - this.buf.items[ptr.offset..][0..ptr.length] - else - ""; - } - - pub const Options = struct { - body: ?*const Blob.Any = null, - }; - - pub fn fromPicoHttpHeaders(headers: []const picohttp.Header, allocator: std.mem.Allocator) !Headers { - const header_count = headers.len; - var result = Headers{ - .entries = .{}, - .buf = .{}, - .allocator = allocator, - }; - - var buf_len: usize = 0; - for (headers) |header| { - buf_len += header.name.len + header.value.len; - } - result.entries.ensureTotalCapacity(allocator, header_count) catch bun.outOfMemory(); - result.entries.len = headers.len; - result.buf.ensureTotalCapacityPrecise(allocator, buf_len) catch bun.outOfMemory(); - result.buf.items.len = buf_len; - var offset: u32 = 0; - for (headers, 0..headers.len) |header, i| { - const name_offset = offset; - bun.copy(u8, result.buf.items[offset..][0..header.name.len], header.name); - offset += @truncate(header.name.len); - const value_offset = offset; - bun.copy(u8, result.buf.items[offset..][0..header.value.len], header.value); - offset += @truncate(header.value.len); - - result.entries.set(i, .{ - .name = .{ - .offset = name_offset, - .length = @truncate(header.name.len), - }, - .value = .{ - .offset = value_offset, - .length = @truncate(header.value.len), - }, - }); - } - return result; - } - - pub fn from(fetch_headers_ref: ?*FetchHeaders, allocator: std.mem.Allocator, options: Options) !Headers { - var header_count: u32 = 0; - var buf_len: u32 = 0; - if (fetch_headers_ref) |headers_ref| - headers_ref.count(&header_count, &buf_len); - var headers = Headers{ - .entries = .{}, - .buf = .{}, - .allocator = allocator, - }; - const buf_len_before_content_type = buf_len; - const needs_content_type = brk: { - if (options.body) |body| { - if (body.hasContentTypeFromUser() and (fetch_headers_ref == null or !fetch_headers_ref.?.fastHas(.ContentType))) { - header_count += 1; - buf_len += @as(u32, @truncate(body.contentType().len + "Content-Type".len)); - break :brk true; - } - } - break :brk false; - }; - headers.entries.ensureTotalCapacity(allocator, header_count) catch bun.outOfMemory(); - headers.entries.len = header_count; - headers.buf.ensureTotalCapacityPrecise(allocator, buf_len) catch bun.outOfMemory(); - headers.buf.items.len = buf_len; - var sliced = headers.entries.slice(); - var names = sliced.items(.name); - var values = sliced.items(.value); - if (fetch_headers_ref) |headers_ref| - headers_ref.copyTo(names.ptr, values.ptr, headers.buf.items.ptr); - - // TODO: maybe we should send Content-Type header first instead of last? - if (needs_content_type) { - bun.copy(u8, headers.buf.items[buf_len_before_content_type..], "Content-Type"); - names[header_count - 1] = .{ - .offset = buf_len_before_content_type, - .length = "Content-Type".len, - }; - - bun.copy(u8, headers.buf.items[buf_len_before_content_type + "Content-Type".len ..], options.body.?.contentType()); - values[header_count - 1] = .{ - .offset = buf_len_before_content_type + @as(u32, "Content-Type".len), - .length = @as(u32, @truncate(options.body.?.contentType().len)), - }; - } - - return headers; - } -}; +pub const Method = @import("./http/Method.zig").Method; +const Api = @import("./api/schema.zig").Api; +const HTTPClient = @This(); +const StringBuilder = bun.StringBuilder; +const posix = std.posix; +const SOCK = posix.SOCK; +const Arena = @import("./allocators/mimalloc_arena.zig").Arena; +const BoringSSL = bun.BoringSSL.c; +const Progress = bun.Progress; +const SSLConfig = @import("./bun.js/api/server.zig").ServerConfig.SSLConfig; +const uws = bun.uws; +const HTTPCertError = @import("./http/HTTPCertError.zig"); +const ProxyTunnel = @import("./http/ProxyTunnel.zig"); +pub const Headers = @import("./http/Headers.zig"); +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 Signals = @import("./http/Signals.zig"); +pub const ThreadSafeStreamBuffer = @import("./http/ThreadSafeStreamBuffer.zig"); +pub const HTTPThread = @import("./http/HTTPThread.zig"); +pub const NewHTTPContext = @import("./http/HTTPContext.zig").NewHTTPContext; +pub const AsyncHTTP = @import("./http/AsyncHTTP.zig"); +pub const InternalState = @import("./http/InternalState.zig"); +pub const CertificateInfo = @import("./http/CertificateInfo.zig"); +pub const FetchRedirect = @import("./http/FetchRedirect.zig").FetchRedirect; +pub const InitError = @import("./http/InitError.zig").InitError; +pub const HTTPRequestBody = @import("./http/HTTPRequestBody.zig").HTTPRequestBody; +pub const SendFile = @import("./http/SendFile.zig"); diff --git a/src/http/AsyncHTTP.zig b/src/http/AsyncHTTP.zig new file mode 100644 index 0000000000..c9d9f3800d --- /dev/null +++ b/src/http/AsyncHTTP.zig @@ -0,0 +1,523 @@ +const AsyncHTTP = @This(); + +request: ?picohttp.Request = null, +response: ?picohttp.Response = null, +request_headers: Headers.Entry.List = .empty, +response_headers: Headers.Entry.List = .empty, +response_buffer: *MutableString, +request_body: HTTPRequestBody = .{ .bytes = "" }, +allocator: std.mem.Allocator, +request_header_buf: string = "", +method: Method = Method.GET, +url: URL, +http_proxy: ?URL = null, +real: ?*AsyncHTTP = null, +next: ?*AsyncHTTP = null, + +task: ThreadPool.Task = ThreadPool.Task{ .callback = &startAsyncHTTP }, +result_callback: HTTPClientResult.Callback = undefined, + +redirected: bool = false, + +response_encoding: Encoding = Encoding.identity, +verbose: HTTPVerboseLevel = .none, + +client: HTTPClient = undefined, +waitingDeffered: bool = false, +finalized: bool = false, +err: ?anyerror = null, +async_http_id: u32 = 0, + +state: AtomicState = AtomicState.init(State.pending), +elapsed: u64 = 0, +gzip_elapsed: u64 = 0, + +signals: Signals = .{}, + +pub var active_requests_count = std.atomic.Value(usize).init(0); +pub var max_simultaneous_requests = std.atomic.Value(usize).init(256); + +pub fn loadEnv(allocator: std.mem.Allocator, logger: *Log, env: *DotEnv.Loader) void { + if (env.get("BUN_CONFIG_MAX_HTTP_REQUESTS")) |max_http_requests| { + const max = std.fmt.parseInt(u16, max_http_requests, 10) catch { + logger.addErrorFmt( + null, + Loc.Empty, + allocator, + "BUN_CONFIG_MAX_HTTP_REQUESTS value \"{s}\" is not a valid integer between 1 and 65535", + .{max_http_requests}, + ) catch unreachable; + return; + }; + if (max == 0) { + logger.addWarningFmt( + null, + Loc.Empty, + allocator, + "BUN_CONFIG_MAX_HTTP_REQUESTS value must be a number between 1 and 65535", + .{}, + ) catch unreachable; + return; + } + AsyncHTTP.max_simultaneous_requests.store(max, .monotonic); + } +} + +pub fn signalHeaderProgress(this: *AsyncHTTP) void { + var progress = this.signals.header_progress orelse return; + progress.store(true, .release); +} + +pub fn enableBodyStreaming(this: *AsyncHTTP) void { + var stream = this.signals.body_streaming orelse return; + stream.store(true, .release); +} + +pub fn clearData(this: *AsyncHTTP) void { + this.response_headers.deinit(this.allocator); + this.response_headers = .{}; + this.request = null; + this.response = null; + this.client.unix_socket_path.deinit(); + this.client.unix_socket_path = JSC.ZigString.Slice.empty; +} + +pub const State = enum(u32) { + pending = 0, + scheduled = 1, + sending = 2, + success = 3, + fail = 4, +}; +const AtomicState = std.atomic.Value(State); + +pub const Options = struct { + http_proxy: ?URL = null, + hostname: ?[]u8 = null, + signals: ?Signals = null, + unix_socket_path: ?JSC.ZigString.Slice = null, + disable_timeout: ?bool = null, + verbose: ?HTTPVerboseLevel = null, + disable_keepalive: ?bool = null, + disable_decompression: ?bool = null, + reject_unauthorized: ?bool = null, + tls_props: ?*SSLConfig = null, +}; + +const Preconnect = struct { + async_http: AsyncHTTP, + response_buffer: MutableString, + url: bun.URL, + is_url_owned: bool, + + pub const new = bun.TrivialNew(@This()); + + pub fn onResult(this: *Preconnect, _: *AsyncHTTP, _: HTTPClientResult) void { + this.response_buffer.deinit(); + this.async_http.clearData(); + this.async_http.client.deinit(); + if (this.is_url_owned) { + bun.default_allocator.free(this.url.href); + } + + bun.destroy(this); + } +}; + +pub fn preconnect( + url: URL, + is_url_owned: bool, +) void { + if (!FeatureFlags.is_fetch_preconnect_supported) { + if (is_url_owned) { + bun.default_allocator.free(url.href); + } + + return; + } + + var this = Preconnect.new(.{ + .async_http = undefined, + .response_buffer = MutableString{ .allocator = bun.http.default_allocator, .list = .{} }, + .url = url, + .is_url_owned = is_url_owned, + }); + + this.async_http = AsyncHTTP.init(bun.default_allocator, .GET, url, .{}, "", &this.response_buffer, "", HTTPClientResult.Callback.New(*Preconnect, Preconnect.onResult).init(this), .manual, .{}); + this.async_http.client.flags.is_preconnect_only = true; + + bun.http.http_thread.schedule(Batch.from(&this.async_http.task)); +} + +pub fn init( + allocator: std.mem.Allocator, + method: Method, + url: URL, + headers: Headers.Entry.List, + headers_buf: string, + response_buffer: *MutableString, + request_body: []const u8, + callback: HTTPClientResult.Callback, + redirect_type: FetchRedirect, + options: Options, +) AsyncHTTP { + var this = AsyncHTTP{ + .allocator = allocator, + .url = url, + .method = method, + .request_headers = headers, + .request_header_buf = headers_buf, + .request_body = .{ .bytes = request_body }, + .response_buffer = response_buffer, + .result_callback = callback, + .http_proxy = options.http_proxy, + .signals = options.signals orelse .{}, + .async_http_id = if (options.signals != null and options.signals.?.aborted != null) bun.http.async_http_id_monotonic.fetchAdd(1, .monotonic) else 0, + }; + + this.client = .{ + .allocator = allocator, + .method = method, + .url = url, + .header_entries = headers, + .header_buf = headers_buf, + .hostname = options.hostname, + .signals = options.signals orelse this.signals, + .async_http_id = this.async_http_id, + .http_proxy = this.http_proxy, + .redirect_type = redirect_type, + }; + if (options.unix_socket_path) |val| { + assert(this.client.unix_socket_path.length() == 0); + this.client.unix_socket_path = val; + } + if (options.disable_timeout) |val| { + this.client.flags.disable_timeout = val; + } + if (options.verbose) |val| { + this.client.verbose = val; + } + if (options.disable_decompression) |val| { + this.client.flags.disable_decompression = val; + } + if (options.disable_keepalive) |val| { + this.client.flags.disable_keepalive = val; + } + if (options.reject_unauthorized) |val| { + this.client.flags.reject_unauthorized = val; + } + if (options.tls_props) |val| { + this.client.tls_props = val; + } + + if (options.http_proxy) |proxy| { + // Username between 0 and 4096 chars + if (proxy.username.len > 0 and proxy.username.len < 4096) { + // Password between 0 and 4096 chars + if (proxy.password.len > 0 and proxy.password.len < 4096) { + // decode password + var password_buffer = std.mem.zeroes([4096]u8); + var password_stream = std.io.fixedBufferStream(&password_buffer); + const password_writer = password_stream.writer(); + const PassWriter = @TypeOf(password_writer); + const password_len = PercentEncoding.decode(PassWriter, password_writer, proxy.password) catch { + // Invalid proxy authorization + return this; + }; + const password = password_buffer[0..password_len]; + + // Decode username + var username_buffer = std.mem.zeroes([4096]u8); + var username_stream = std.io.fixedBufferStream(&username_buffer); + const username_writer = username_stream.writer(); + const UserWriter = @TypeOf(username_writer); + const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { + // Invalid proxy authorization + return this; + }; + const username = username_buffer[0..username_len]; + + // concat user and password + const auth = std.fmt.allocPrint(allocator, "{s}:{s}", .{ username, password }) catch unreachable; + defer allocator.free(auth); + const size = std.base64.standard.Encoder.calcSize(auth.len); + var buf = this.allocator.alloc(u8, size + "Basic ".len) catch unreachable; + const encoded = std.base64.url_safe.Encoder.encode(buf["Basic ".len..], auth); + buf[0.."Basic ".len].* = "Basic ".*; + this.client.proxy_authorization = buf[0 .. "Basic ".len + encoded.len]; + } else { + //Decode username + var username_buffer = std.mem.zeroes([4096]u8); + var username_stream = std.io.fixedBufferStream(&username_buffer); + const username_writer = username_stream.writer(); + const UserWriter = @TypeOf(username_writer); + const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { + // Invalid proxy authorization + return this; + }; + const username = username_buffer[0..username_len]; + + // only use user + const size = std.base64.standard.Encoder.calcSize(username_len); + var buf = allocator.alloc(u8, size + "Basic ".len) catch unreachable; + const encoded = std.base64.url_safe.Encoder.encode(buf["Basic ".len..], username); + buf[0.."Basic ".len].* = "Basic ".*; + this.client.proxy_authorization = buf[0 .. "Basic ".len + encoded.len]; + } + } + } + return this; +} + +pub fn initSync( + allocator: std.mem.Allocator, + method: Method, + url: URL, + headers: Headers.Entry.List, + headers_buf: string, + response_buffer: *MutableString, + request_body: []const u8, + http_proxy: ?URL, + hostname: ?[]u8, + redirect_type: FetchRedirect, +) AsyncHTTP { + return @This().init( + allocator, + method, + url, + headers, + headers_buf, + response_buffer, + request_body, + undefined, + redirect_type, + .{ + .http_proxy = http_proxy, + .hostname = hostname, + }, + ); +} + +fn reset(this: *AsyncHTTP) !void { + const aborted = this.client.aborted; + this.client = try HTTPClient.init(this.allocator, this.method, this.client.url, this.client.header_entries, this.client.header_buf, aborted); + this.client.http_proxy = this.http_proxy; + + if (this.http_proxy) |proxy| { + //TODO: need to understand how is possible to reuse Proxy with TSL, so disable keepalive if url is HTTPS + this.client.flags.disable_keepalive = this.url.isHTTPS(); + // Username between 0 and 4096 chars + if (proxy.username.len > 0 and proxy.username.len < 4096) { + // Password between 0 and 4096 chars + if (proxy.password.len > 0 and proxy.password.len < 4096) { + // decode password + var password_buffer = std.mem.zeroes([4096]u8); + var password_stream = std.io.fixedBufferStream(&password_buffer); + const password_writer = password_stream.writer(); + const PassWriter = @TypeOf(password_writer); + const password_len = PercentEncoding.decode(PassWriter, password_writer, proxy.password) catch { + // Invalid proxy authorization + return this; + }; + const password = password_buffer[0..password_len]; + + // Decode username + var username_buffer = std.mem.zeroes([4096]u8); + var username_stream = std.io.fixedBufferStream(&username_buffer); + const username_writer = username_stream.writer(); + const UserWriter = @TypeOf(username_writer); + const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { + // Invalid proxy authorization + return this; + }; + + const username = username_buffer[0..username_len]; + + // concat user and password + const auth = std.fmt.allocPrint(this.allocator, "{s}:{s}", .{ username, password }) catch unreachable; + defer this.allocator.free(auth); + const size = std.base64.standard.Encoder.calcSize(auth.len); + var buf = this.allocator.alloc(u8, size + "Basic ".len) catch unreachable; + const encoded = std.base64.url_safe.Encoder.encode(buf["Basic ".len..], auth); + buf[0.."Basic ".len].* = "Basic ".*; + this.client.proxy_authorization = buf[0 .. "Basic ".len + encoded.len]; + } else { + //Decode username + var username_buffer = std.mem.zeroes([4096]u8); + var username_stream = std.io.fixedBufferStream(&username_buffer); + const username_writer = username_stream.writer(); + const UserWriter = @TypeOf(username_writer); + const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { + // Invalid proxy authorization + return this; + }; + const username = username_buffer[0..username_len]; + + // only use user + const size = std.base64.standard.Encoder.calcSize(username_len); + var buf = this.allocator.alloc(u8, size + "Basic ".len) catch unreachable; + const encoded = std.base64.url_safe.Encoder.encode(buf["Basic ".len..], username); + buf[0.."Basic ".len].* = "Basic ".*; + this.client.proxy_authorization = buf[0 .. "Basic ".len + encoded.len]; + } + } + } +} + +pub fn schedule(this: *AsyncHTTP, _: std.mem.Allocator, batch: *ThreadPool.Batch) void { + this.state.store(.scheduled, .monotonic); + batch.push(ThreadPool.Batch.from(&this.task)); +} + +fn sendSyncCallback(this: *SingleHTTPChannel, async_http: *AsyncHTTP, result: HTTPClientResult) void { + async_http.real.?.* = async_http.*; + async_http.real.?.response_buffer = async_http.response_buffer; + this.channel.writeItem(result) catch unreachable; +} + +pub fn sendSync(this: *AsyncHTTP) anyerror!picohttp.Response { + HTTPThread.init(&.{}); + + var ctx = try bun.default_allocator.create(SingleHTTPChannel); + ctx.* = SingleHTTPChannel.init(); + this.result_callback = HTTPClientResult.Callback.New( + *SingleHTTPChannel, + sendSyncCallback, + ).init(ctx); + + var batch = bun.ThreadPool.Batch{}; + this.schedule(bun.default_allocator, &batch); + bun.http.http_thread.schedule(batch); + + const result = ctx.channel.readItem() catch unreachable; + if (result.fail) |err| { + return err; + } + assert(result.metadata != null); + return result.metadata.?.response; +} + +pub fn onAsyncHTTPCallback(this: *AsyncHTTP, async_http: *AsyncHTTP, result: HTTPClientResult) void { + assert(this.real != null); + + var callback = this.result_callback; + this.elapsed = bun.http.http_thread.timer.read() -| this.elapsed; + + // TODO: this condition seems wrong: if we started with a non-default value, we might + // report a redirect even if none happened + this.redirected = this.client.flags.redirected; + if (result.isSuccess()) { + this.err = null; + if (result.metadata) |metadata| { + this.response = metadata.response; + } + this.state.store(.success, .monotonic); + } else { + this.err = result.fail; + this.response = null; + this.state.store(State.fail, .monotonic); + } + + if (comptime Environment.enable_logs) { + if (bun.http.socket_async_http_abort_tracker.count() > 0) { + log("bun.http.socket_async_http_abort_tracker count: {d}", .{bun.http.socket_async_http_abort_tracker.count()}); + } + } + + if (bun.http.socket_async_http_abort_tracker.capacity() > 10_000 and bun.http.socket_async_http_abort_tracker.count() < 100) { + bun.http.socket_async_http_abort_tracker.shrinkAndFree(bun.http.socket_async_http_abort_tracker.count()); + } + + if (result.has_more) { + callback.function(callback.ctx, async_http, result); + } else { + { + this.client.deinit(); + var threadlocal_http: *bun.http.ThreadlocalAsyncHTTP = @fieldParentPtr("async_http", async_http); + defer threadlocal_http.deinit(); + log("onAsyncHTTPCallback: {any}", .{std.fmt.fmtDuration(this.elapsed)}); + callback.function(callback.ctx, async_http, result); + } + + const active_requests = AsyncHTTP.active_requests_count.fetchSub(1, .monotonic); + assert(active_requests > 0); + } + + if (!bun.http.http_thread.queued_tasks.isEmpty() and AsyncHTTP.active_requests_count.load(.monotonic) < AsyncHTTP.max_simultaneous_requests.load(.monotonic)) { + bun.http.http_thread.loop.loop.wakeup(); + } +} + +pub fn startAsyncHTTP(task: *Task) void { + var this: *AsyncHTTP = @fieldParentPtr("task", task); + this.onStart(); +} + +pub fn onStart(this: *AsyncHTTP) void { + _ = active_requests_count.fetchAdd(1, .monotonic); + this.err = null; + this.state.store(.sending, .monotonic); + this.client.result_callback = HTTPClientResult.Callback.New(*AsyncHTTP, onAsyncHTTPCallback).init( + this, + ); + + this.elapsed = bun.http.http_thread.timer.read(); + if (this.response_buffer.list.capacity == 0) { + this.response_buffer.allocator = bun.http.default_allocator; + } + this.client.start(this.request_body, this.response_buffer); +} + +const std = @import("std"); +const bun = @import("bun"); +const assert = bun.assert; +const picohttp = bun.picohttp; +const string = bun.string; +const Environment = bun.Environment; +const FeatureFlags = bun.FeatureFlags; +const JSC = bun.JSC; +const Loc = bun.logger.Loc; +const Log = bun.logger.Log; + +const HTTPClient = bun.http; +const Method = HTTPClient.Method; +const HTTPClientResult = HTTPClient.HTTPClientResult; +const HTTPVerboseLevel = HTTPClient.HTTPVerboseLevel; +const HTTPRequestBody = HTTPClient.HTTPRequestBody; +const FetchRedirect = HTTPClient.FetchRedirect; +const Signals = HTTPClient.Signals; +const Encoding = @import("./Encoding.zig").Encoding; +const URL = @import("../url.zig").URL; +const PercentEncoding = @import("../url.zig").PercentEncoding; +const MutableString = bun.MutableString; +const Headers = @import("./Headers.zig"); +const HTTPThread = @import("./HTTPThread.zig"); +const DotEnv = @import("../env_loader.zig"); +const log = bun.Output.scoped(.AsyncHTTP, false); +const ThreadPool = bun.ThreadPool; +const Task = ThreadPool.Task; +const Batch = bun.ThreadPool.Batch; +const SSLConfig = @import("../bun.js/api/server.zig").ServerConfig.SSLConfig; + +const HTTPCallbackPair = .{ *AsyncHTTP, HTTPClientResult }; +const Channel = @import("../sync.zig").Channel; +pub const HTTPChannel = Channel(HTTPCallbackPair, .{ .Static = 1000 }); +// 32 pointers much cheaper than 1000 pointers +const SingleHTTPChannel = struct { + const SingleHTTPCHannel_ = Channel(HTTPClientResult, .{ .Static = 8 }); + channel: SingleHTTPCHannel_, + pub fn reset(_: *@This()) void {} + pub fn init() SingleHTTPChannel { + return SingleHTTPChannel{ .channel = SingleHTTPCHannel_.init() }; + } +}; + +pub const HTTPChannelContext = struct { + http: AsyncHTTP = undefined, + channel: *HTTPChannel, + + pub fn callback(data: HTTPCallbackPair) void { + var this: *HTTPChannelContext = @fieldParentPtr("http", data.@"0"); + this.channel.writeItem(data) catch unreachable; + } +}; diff --git a/src/http/CertificateInfo.zig b/src/http/CertificateInfo.zig new file mode 100644 index 0000000000..7adb777755 --- /dev/null +++ b/src/http/CertificateInfo.zig @@ -0,0 +1,14 @@ +const CertificateInfo = @This(); + +cert: []const u8, +cert_error: HTTPCertError, +hostname: []const u8, +pub fn deinit(this: *const CertificateInfo, allocator: std.mem.Allocator) void { + allocator.free(this.cert); + allocator.free(this.cert_error.code); + allocator.free(this.cert_error.reason); + allocator.free(this.hostname); +} + +const std = @import("std"); +const HTTPCertError = @import("./HTTPCertError.zig"); diff --git a/src/http/Decompressor.zig b/src/http/Decompressor.zig new file mode 100644 index 0000000000..d6d20939ec --- /dev/null +++ b/src/http/Decompressor.zig @@ -0,0 +1,120 @@ +pub const Decompressor = union(enum) { + zlib: *Zlib.ZlibReaderArrayList, + brotli: *Brotli.BrotliReaderArrayList, + zstd: *zstd.ZstdReaderArrayList, + none: void, + + pub fn deinit(this: *Decompressor) void { + switch (this.*) { + inline .brotli, .zlib, .zstd => |that| { + that.deinit(); + this.* = .{ .none = {} }; + }, + .none => {}, + } + } + + pub fn updateBuffers(this: *Decompressor, encoding: Encoding, buffer: []const u8, body_out_str: *MutableString) !void { + if (!encoding.isCompressed()) { + return; + } + + if (this.* == .none) { + switch (encoding) { + .gzip, .deflate => { + this.* = .{ + .zlib = try Zlib.ZlibReaderArrayList.initWithOptionsAndListAllocator( + buffer, + &body_out_str.list, + body_out_str.allocator, + bun.http.default_allocator, + .{ + // zlib.MAX_WBITS = 15 + // to (de-)compress deflate format, use wbits = -zlib.MAX_WBITS + // to (de-)compress deflate format with headers we use wbits = 0 (we can detect the first byte using 120) + // to (de-)compress gzip format, use wbits = zlib.MAX_WBITS | 16 + .windowBits = if (encoding == Encoding.gzip) Zlib.MAX_WBITS | 16 else (if (buffer.len > 1 and buffer[0] == 120) 0 else -Zlib.MAX_WBITS), + }, + ), + }; + return; + }, + .brotli => { + this.* = .{ + .brotli = try Brotli.BrotliReaderArrayList.newWithOptions( + buffer, + &body_out_str.list, + body_out_str.allocator, + .{}, + ), + }; + return; + }, + .zstd => { + this.* = .{ + .zstd = try zstd.ZstdReaderArrayList.initWithListAllocator( + buffer, + &body_out_str.list, + body_out_str.allocator, + bun.http.default_allocator, + ), + }; + return; + }, + else => @panic("Invalid encoding. This code should not be reachable"), + } + } + + switch (this.*) { + .zlib => |reader| { + bun.assert(reader.zlib.avail_in == 0); + reader.zlib.next_in = buffer.ptr; + reader.zlib.avail_in = @as(u32, @truncate(buffer.len)); + + const initial = body_out_str.list.items.len; + body_out_str.list.expandToCapacity(); + if (body_out_str.list.capacity == initial) { + try body_out_str.list.ensureUnusedCapacity(body_out_str.allocator, 4096); + body_out_str.list.expandToCapacity(); + } + reader.list = body_out_str.list; + reader.zlib.next_out = @ptrCast(&body_out_str.list.items[initial]); + reader.zlib.avail_out = @as(u32, @truncate(body_out_str.list.capacity - initial)); + // we reset the total out so we can track how much we decompressed this time + reader.zlib.total_out = @truncate(initial); + }, + .brotli => |reader| { + reader.input = buffer; + reader.total_in = 0; + + const initial = body_out_str.list.items.len; + reader.list = body_out_str.list; + reader.total_out = @truncate(initial); + }, + .zstd => |reader| { + reader.input = buffer; + reader.total_in = 0; + + const initial = body_out_str.list.items.len; + reader.list = body_out_str.list; + reader.total_out = @truncate(initial); + }, + else => @panic("Invalid encoding. This code should not be reachable"), + } + } + + pub fn readAll(this: *Decompressor, is_done: bool) !void { + switch (this.*) { + .zlib => |zlib| try zlib.readAll(), + .brotli => |brotli| try brotli.readAll(is_done), + .zstd => |reader| try reader.readAll(is_done), + .none => {}, + } + } +}; +const bun = @import("bun"); +const MutableString = bun.MutableString; +const Zlib = @import("../zlib.zig"); +const Brotli = bun.brotli; +const zstd = bun.zstd; +const Encoding = @import("./Encoding.zig").Encoding; diff --git a/src/http/Encoding.zig b/src/http/Encoding.zig new file mode 100644 index 0000000000..5a4b046bd0 --- /dev/null +++ b/src/http/Encoding.zig @@ -0,0 +1,22 @@ +pub const Encoding = enum { + identity, + gzip, + deflate, + brotli, + zstd, + chunked, + + pub fn canUseLibDeflate(this: Encoding) bool { + return switch (this) { + .gzip, .deflate => true, + else => false, + }; + } + + pub fn isCompressed(this: Encoding) bool { + return switch (this) { + .brotli, .gzip, .deflate, .zstd => true, + else => false, + }; + } +}; diff --git a/src/http/FetchRedirect.zig b/src/http/FetchRedirect.zig new file mode 100644 index 0000000000..9c0f34121b --- /dev/null +++ b/src/http/FetchRedirect.zig @@ -0,0 +1,13 @@ +pub const FetchRedirect = enum(u8) { + follow, + manual, + @"error", + + pub const Map = bun.ComptimeStringMap(FetchRedirect, .{ + .{ "follow", .follow }, + .{ "manual", .manual }, + .{ "error", .@"error" }, + }); +}; + +const bun = @import("bun"); diff --git a/src/http/HTTPCertError.zig b/src/http/HTTPCertError.zig new file mode 100644 index 0000000000..8112703440 --- /dev/null +++ b/src/http/HTTPCertError.zig @@ -0,0 +1,3 @@ +error_no: i32 = 0, +code: [:0]const u8 = "", +reason: [:0]const u8 = "", diff --git a/src/http/HTTPContext.zig b/src/http/HTTPContext.zig new file mode 100644 index 0000000000..aae2e0e5cb --- /dev/null +++ b/src/http/HTTPContext.zig @@ -0,0 +1,506 @@ +pub fn NewHTTPContext(comptime ssl: bool) type { + return struct { + const pool_size = 64; + const PooledSocket = struct { + http_socket: HTTPSocket, + hostname_buf: [MAX_KEEPALIVE_HOSTNAME]u8 = undefined, + hostname_len: u8 = 0, + port: u16 = 0, + /// If you set `rejectUnauthorized` to `false`, the connection fails to verify, + did_have_handshaking_error_while_reject_unauthorized_is_false: bool = false, + }; + + pub fn markSocketAsDead(socket: HTTPSocket) void { + if (socket.ext(**anyopaque)) |ctx| { + ctx.* = bun.cast(**anyopaque, ActiveSocket.init(&dead_socket).ptr()); + } + } + + pub fn terminateSocket(socket: HTTPSocket) void { + markSocketAsDead(socket); + socket.close(.failure); + } + + pub fn closeSocket(socket: HTTPSocket) void { + markSocketAsDead(socket); + socket.close(.normal); + } + + fn getTagged(ptr: *anyopaque) ActiveSocket { + return ActiveSocket.from(bun.cast(**anyopaque, ptr).*); + } + + pub fn getTaggedFromSocket(socket: HTTPSocket) ActiveSocket { + if (socket.ext(anyopaque)) |ctx| { + return getTagged(ctx); + } + return ActiveSocket.init(&dead_socket); + } + + pub const PooledSocketHiveAllocator = bun.HiveArray(PooledSocket, pool_size); + + pending_sockets: PooledSocketHiveAllocator, + us_socket_context: *uws.SocketContext, + + const Context = @This(); + pub const HTTPSocket = uws.NewSocketHandler(ssl); + + pub fn context() *@This() { + if (comptime ssl) { + return &bun.http.http_thread.https_context; + } else { + return &bun.http.http_thread.http_context; + } + } + + const ActiveSocket = TaggedPointerUnion(.{ + *DeadSocket, + HTTPClient, + PooledSocket, + }); + const ssl_int = @as(c_int, @intFromBool(ssl)); + + const MAX_KEEPALIVE_HOSTNAME = 128; + + pub fn sslCtx(this: *@This()) *BoringSSL.SSL_CTX { + if (comptime !ssl) { + unreachable; + } + + return @as(*BoringSSL.SSL_CTX, @ptrCast(this.us_socket_context.getNativeHandle(true))); + } + + pub fn deinit(this: *@This()) void { + this.us_socket_context.deinit(ssl); + bun.default_allocator.destroy(this); + } + + pub fn initWithClientConfig(this: *@This(), client: *HTTPClient) InitError!void { + if (!comptime ssl) { + @compileError("ssl only"); + } + var opts = client.tls_props.?.asUSockets(); + opts.request_cert = 1; + opts.reject_unauthorized = 0; + try this.initWithOpts(&opts); + } + + fn initWithOpts(this: *@This(), opts: *const uws.SocketContext.BunSocketContextOptions) InitError!void { + if (!comptime ssl) { + @compileError("ssl only"); + } + + var err: uws.create_bun_socket_error_t = .none; + const socket = uws.SocketContext.createSSLContext(bun.http.http_thread.loop.loop, @sizeOf(usize), opts.*, &err); + if (socket == null) { + return switch (err) { + .load_ca_file => error.LoadCAFile, + .invalid_ca_file => error.InvalidCAFile, + .invalid_ca => error.InvalidCA, + else => error.FailedToOpenSocket, + }; + } + this.us_socket_context = socket.?; + this.sslCtx().setup(); + + HTTPSocket.configure( + this.us_socket_context, + false, + anyopaque, + Handler, + ); + } + + pub fn initWithThreadOpts(this: *@This(), init_opts: *const HTTPThread.InitOpts) InitError!void { + if (!comptime ssl) { + @compileError("ssl only"); + } + var opts: uws.SocketContext.BunSocketContextOptions = .{ + .ca = if (init_opts.ca.len > 0) @ptrCast(init_opts.ca) else null, + .ca_count = @intCast(init_opts.ca.len), + .ca_file_name = if (init_opts.abs_ca_file_name.len > 0) init_opts.abs_ca_file_name else null, + .request_cert = 1, + }; + + try this.initWithOpts(&opts); + } + + pub fn init(this: *@This()) void { + if (comptime ssl) { + const opts: uws.SocketContext.BunSocketContextOptions = .{ + // we request the cert so we load root certs and can verify it + .request_cert = 1, + // we manually abort the connection if the hostname doesn't match + .reject_unauthorized = 0, + }; + var err: uws.create_bun_socket_error_t = .none; + this.us_socket_context = uws.SocketContext.createSSLContext(bun.http.http_thread.loop.loop, @sizeOf(usize), opts, &err).?; + + this.sslCtx().setup(); + } else { + this.us_socket_context = uws.SocketContext.createNoSSLContext(bun.http.http_thread.loop.loop, @sizeOf(usize)).?; + } + + HTTPSocket.configure( + this.us_socket_context, + false, + anyopaque, + Handler, + ); + } + + /// Attempt to keep the socket alive by reusing it for another request. + /// If no space is available, close the socket. + /// + /// If `did_have_handshaking_error_while_reject_unauthorized_is_false` + /// is set, then we can only reuse the socket for HTTP Keep Alive if + /// `reject_unauthorized` is set to `false`. + pub fn releaseSocket(this: *@This(), socket: HTTPSocket, did_have_handshaking_error_while_reject_unauthorized_is_false: bool, hostname: []const u8, port: u16) void { + // log("releaseSocket(0x{})", .{bun.fmt.hexIntUpper(@intFromPtr(socket.socket))}); + + if (comptime Environment.allow_assert) { + assert(!socket.isClosed()); + assert(!socket.isShutdown()); + assert(socket.isEstablished()); + } + assert(hostname.len > 0); + assert(port > 0); + + if (hostname.len <= MAX_KEEPALIVE_HOSTNAME and !socket.isClosedOrHasError() and socket.isEstablished()) { + if (this.pending_sockets.get()) |pending| { + if (socket.ext(**anyopaque)) |ctx| { + ctx.* = bun.cast(**anyopaque, ActiveSocket.init(pending).ptr()); + } + socket.flush(); + socket.timeout(0); + socket.setTimeoutMinutes(5); + + pending.http_socket = socket; + pending.did_have_handshaking_error_while_reject_unauthorized_is_false = did_have_handshaking_error_while_reject_unauthorized_is_false; + @memcpy(pending.hostname_buf[0..hostname.len], hostname); + pending.hostname_len = @as(u8, @truncate(hostname.len)); + pending.port = port; + + log("Keep-Alive release {s}:{d}", .{ + hostname, + port, + }); + return; + } + } + log("close socket", .{}); + closeSocket(socket); + } + + pub const Handler = struct { + pub fn onOpen( + ptr: *anyopaque, + socket: HTTPSocket, + ) void { + const active = getTagged(ptr); + if (active.get(HTTPClient)) |client| { + if (client.onOpen(comptime ssl, socket)) |_| { + return; + } else |_| { + log("Unable to open socket", .{}); + terminateSocket(socket); + return; + } + } + + if (active.get(PooledSocket)) |pooled| { + addMemoryBackToPool(pooled); + return; + } + + log("Unexpected open on unknown socket", .{}); + terminateSocket(socket); + } + pub fn onHandshake( + ptr: *anyopaque, + socket: HTTPSocket, + success: i32, + ssl_error: uws.us_bun_verify_error_t, + ) void { + const handshake_success = if (success == 1) true else false; + + const handshake_error = HTTPCertError{ + .error_no = ssl_error.error_no, + .code = if (ssl_error.code == null) "" else ssl_error.code[0..bun.len(ssl_error.code) :0], + .reason = if (ssl_error.code == null) "" else ssl_error.reason[0..bun.len(ssl_error.reason) :0], + }; + + const active = getTagged(ptr); + if (active.get(HTTPClient)) |client| { + // handshake completed but we may have ssl errors + client.flags.did_have_handshaking_error = handshake_error.error_no != 0; + if (handshake_success) { + if (client.flags.reject_unauthorized) { + // only reject the connection if reject_unauthorized == true + if (client.flags.did_have_handshaking_error) { + client.closeAndFail(BoringSSL.getCertErrorFromNo(handshake_error.error_no), comptime ssl, socket); + return; + } + + // if checkServerIdentity returns false, we dont call open this means that the connection was rejected + const ssl_ptr = @as(*BoringSSL.SSL, @ptrCast(socket.getNativeHandle())); + if (!client.checkServerIdentity(comptime ssl, socket, handshake_error, ssl_ptr, true)) { + client.flags.did_have_handshaking_error = true; + client.unregisterAbortTracker(); + if (!socket.isClosed()) terminateSocket(socket); + return; + } + } + + return client.firstCall(comptime ssl, socket); + } else { + // if we are here is because server rejected us, and the error_no is the cause of this + // if we set reject_unauthorized == false this means the server requires custom CA aka NODE_EXTRA_CA_CERTS + if (client.flags.did_have_handshaking_error) { + client.closeAndFail(BoringSSL.getCertErrorFromNo(handshake_error.error_no), comptime ssl, socket); + return; + } + // if handshake_success it self is false, this means that the connection was rejected + client.closeAndFail(error.ConnectionRefused, comptime ssl, socket); + return; + } + } + + if (socket.isClosed()) { + markSocketAsDead(socket); + if (active.get(PooledSocket)) |pooled| { + addMemoryBackToPool(pooled); + } + + return; + } + + if (handshake_success) { + if (active.is(PooledSocket)) { + // Allow pooled sockets to be reused if the handshake was successful. + socket.setTimeout(0); + socket.setTimeoutMinutes(5); + return; + } + } + + if (active.get(PooledSocket)) |pooled| { + addMemoryBackToPool(pooled); + } + + terminateSocket(socket); + } + pub fn onClose( + ptr: *anyopaque, + socket: HTTPSocket, + _: c_int, + _: ?*anyopaque, + ) void { + const tagged = getTagged(ptr); + markSocketAsDead(socket); + + if (tagged.get(HTTPClient)) |client| { + return client.onClose(comptime ssl, socket); + } + + if (tagged.get(PooledSocket)) |pooled| { + addMemoryBackToPool(pooled); + } + + return; + } + + fn addMemoryBackToPool(pooled: *PooledSocket) void { + assert(context().pending_sockets.put(pooled)); + } + + pub fn onData( + ptr: *anyopaque, + socket: HTTPSocket, + buf: []const u8, + ) void { + const tagged = getTagged(ptr); + if (tagged.get(HTTPClient)) |client| { + return client.onData( + comptime ssl, + buf, + if (comptime ssl) &bun.http.http_thread.https_context else &bun.http.http_thread.http_context, + socket, + ); + } else if (tagged.is(PooledSocket)) { + // trailing zero is fine to ignore + if (strings.eqlComptime(buf, bun.http.end_of_chunked_http1_1_encoding_response_body)) { + return; + } + + log("Unexpected data on socket", .{}); + + return; + } + log("Unexpected data on unknown socket", .{}); + terminateSocket(socket); + } + pub fn onWritable( + ptr: *anyopaque, + socket: HTTPSocket, + ) void { + const tagged = getTagged(ptr); + if (tagged.get(HTTPClient)) |client| { + return client.onWritable( + false, + comptime ssl, + socket, + ); + } else if (tagged.is(PooledSocket)) { + // it's a keep-alive socket + } else { + // don't know what this is, let's close it + log("Unexpected writable on socket", .{}); + terminateSocket(socket); + } + } + pub fn onLongTimeout( + ptr: *anyopaque, + socket: HTTPSocket, + ) void { + const tagged = getTagged(ptr); + if (tagged.get(HTTPClient)) |client| { + return client.onTimeout(comptime ssl, socket); + } else if (tagged.get(PooledSocket)) |pooled| { + // If a socket has been sitting around for 5 minutes + // Let's close it and remove it from the pool. + addMemoryBackToPool(pooled); + } + + terminateSocket(socket); + } + pub fn onConnectError( + ptr: *anyopaque, + socket: HTTPSocket, + _: c_int, + ) void { + const tagged = getTagged(ptr); + markSocketAsDead(socket); + if (tagged.get(HTTPClient)) |client| { + client.onConnectError(); + } else if (tagged.get(PooledSocket)) |pooled| { + addMemoryBackToPool(pooled); + } + // us_connecting_socket_close is always called internally by uSockets + } + pub fn onEnd( + _: *anyopaque, + socket: HTTPSocket, + ) void { + // TCP fin must be closed, but we must keep the original tagged + // pointer so that their onClose callback is called. + // + // Three possible states: + // 1. HTTP Keep-Alive socket: it must be removed from the pool + // 2. HTTP Client socket: it might need to be retried + // 3. Dead socket: it is already marked as dead + socket.close(.failure); + } + }; + + fn existingSocket(this: *@This(), reject_unauthorized: bool, hostname: []const u8, port: u16) ?HTTPSocket { + if (hostname.len > MAX_KEEPALIVE_HOSTNAME) + return null; + + var iter = this.pending_sockets.used.iterator(.{ .kind = .set }); + + while (iter.next()) |pending_socket_index| { + var socket = this.pending_sockets.at(@as(u16, @intCast(pending_socket_index))); + if (socket.port != port) { + continue; + } + + if (socket.did_have_handshaking_error_while_reject_unauthorized_is_false and reject_unauthorized) { + continue; + } + + if (strings.eqlLong(socket.hostname_buf[0..socket.hostname_len], hostname, true)) { + const http_socket = socket.http_socket; + assert(context().pending_sockets.put(socket)); + + if (http_socket.isClosed()) { + markSocketAsDead(http_socket); + continue; + } + + if (http_socket.isShutdown() or http_socket.getError() != 0) { + terminateSocket(http_socket); + continue; + } + + log("+ Keep-Alive reuse {s}:{d}", .{ hostname, port }); + return http_socket; + } + } + + return null; + } + + pub fn connectSocket(this: *@This(), client: *HTTPClient, socket_path: []const u8) !HTTPSocket { + client.connected_url = if (client.http_proxy) |proxy| proxy else client.url; + const socket = try HTTPSocket.connectUnixAnon( + socket_path, + this.us_socket_context, + ActiveSocket.init(client).ptr(), + false, // dont allow half-open sockets + ); + client.allow_retry = false; + return socket; + } + + pub fn connect(this: *@This(), client: *HTTPClient, hostname_: []const u8, port: u16) !HTTPSocket { + const hostname = if (FeatureFlags.hardcode_localhost_to_127_0_0_1 and strings.eqlComptime(hostname_, "localhost")) + "127.0.0.1" + else + hostname_; + + client.connected_url = if (client.http_proxy) |proxy| proxy else client.url; + client.connected_url.hostname = hostname; + + if (client.isKeepAlivePossible()) { + if (this.existingSocket(client.flags.reject_unauthorized, hostname, port)) |sock| { + if (sock.ext(**anyopaque)) |ctx| { + ctx.* = bun.cast(**anyopaque, ActiveSocket.init(client).ptr()); + } + client.allow_retry = true; + try client.onOpen(comptime ssl, sock); + if (comptime ssl) { + client.firstCall(comptime ssl, sock); + } + return sock; + } + } + + const socket = try HTTPSocket.connectAnon( + hostname, + port, + this.us_socket_context, + ActiveSocket.init(client).ptr(), + false, + ); + client.allow_retry = false; + return socket; + } + }; +} +const bun = @import("bun"); +const uws = bun.uws; +const BoringSSL = bun.BoringSSL.c; +const strings = bun.strings; +const Environment = bun.Environment; +const FeatureFlags = bun.FeatureFlags; +const assert = bun.assert; +const HTTPThread = @import("./HTTPThread.zig"); +const HTTPCertError = @import("./HTTPCertError.zig"); +const HTTPClient = bun.http; +const InitError = HTTPClient.InitError; +const TaggedPointerUnion = @import("../ptr.zig").TaggedPointerUnion; + +const DeadSocket = opaque {}; +var dead_socket = @as(*DeadSocket, @ptrFromInt(1)); +const log = bun.Output.scoped(.HTTPContext, true); diff --git a/src/http/HTTPRequestBody.zig b/src/http/HTTPRequestBody.zig new file mode 100644 index 0000000000..bb5e56db12 --- /dev/null +++ b/src/http/HTTPRequestBody.zig @@ -0,0 +1,37 @@ +pub const HTTPRequestBody = union(enum) { + bytes: []const u8, + sendfile: SendFile, + stream: struct { + buffer: ?*ThreadSafeStreamBuffer, + ended: bool, + + pub fn detach(this: *@This()) void { + if (this.buffer) |buffer| { + this.buffer = null; + buffer.deref(); + } + } + }, + + pub fn isStream(this: *const HTTPRequestBody) bool { + return this.* == .stream; + } + + pub fn deinit(this: *HTTPRequestBody) void { + switch (this.*) { + .sendfile, .bytes => {}, + .stream => |*stream| stream.detach(), + } + } + pub fn len(this: *const HTTPRequestBody) usize { + return switch (this.*) { + .bytes => this.bytes.len, + .sendfile => this.sendfile.content_size, + // unknow amounts + .stream => std.math.maxInt(usize), + }; + } +}; +const std = @import("std"); +const SendFile = @import("./SendFile.zig"); +const ThreadSafeStreamBuffer = @import("./ThreadSafeStreamBuffer.zig"); diff --git a/src/http/HTTPThread.zig b/src/http/HTTPThread.zig new file mode 100644 index 0000000000..23e1a088e6 --- /dev/null +++ b/src/http/HTTPThread.zig @@ -0,0 +1,481 @@ +var custom_ssl_context_map = std.AutoArrayHashMap(*SSLConfig, *NewHTTPContext(true)).init(bun.default_allocator); +const HTTPThread = @This(); + +loop: *JSC.MiniEventLoop, +http_context: NewHTTPContext(false), +https_context: NewHTTPContext(true), + +queued_tasks: Queue = Queue{}, + +queued_shutdowns: std.ArrayListUnmanaged(ShutdownMessage) = std.ArrayListUnmanaged(ShutdownMessage){}, +queued_writes: std.ArrayListUnmanaged(WriteMessage) = std.ArrayListUnmanaged(WriteMessage){}, + +queued_shutdowns_lock: bun.Mutex = .{}, +queued_writes_lock: bun.Mutex = .{}, + +queued_proxy_deref: std.ArrayListUnmanaged(*ProxyTunnel) = std.ArrayListUnmanaged(*ProxyTunnel){}, + +has_awoken: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), +timer: std.time.Timer, +lazy_libdeflater: ?*LibdeflateState = null, +lazy_request_body_buffer: ?*HeapRequestBodyBuffer = null, + +pub const HeapRequestBodyBuffer = struct { + buffer: [512 * 1024]u8 = undefined, + fixed_buffer_allocator: std.heap.FixedBufferAllocator, + + pub const new = bun.TrivialNew(@This()); + pub const deinit = bun.TrivialDeinit(@This()); + + pub fn init() *@This() { + var this = HeapRequestBodyBuffer.new(.{ + .fixed_buffer_allocator = undefined, + }); + this.fixed_buffer_allocator = std.heap.FixedBufferAllocator.init(&this.buffer); + return this; + } + + pub fn put(this: *@This()) void { + if (bun.http.http_thread.lazy_request_body_buffer == null) { + // This case hypothetically should never happen + this.fixed_buffer_allocator.reset(); + bun.http.http_thread.lazy_request_body_buffer = this; + } else { + this.deinit(); + } + } +}; + +pub const RequestBodyBuffer = union(enum) { + heap: *HeapRequestBodyBuffer, + stack: std.heap.StackFallbackAllocator(request_body_send_stack_buffer_size), + + pub fn deinit(this: *@This()) void { + switch (this.*) { + .heap => |heap| heap.put(), + .stack => {}, + } + } + + pub fn allocatedSlice(this: *@This()) []u8 { + return switch (this.*) { + .heap => |heap| &heap.buffer, + .stack => |*stack| &stack.buffer, + }; + } + + pub fn allocator(this: *@This()) std.mem.Allocator { + return switch (this.*) { + .heap => |heap| heap.fixed_buffer_allocator.allocator(), + .stack => |*stack| stack.get(), + }; + } + + pub fn toArrayList(this: *@This()) std.ArrayList(u8) { + var arraylist = std.ArrayList(u8).fromOwnedSlice(this.allocator(), this.allocatedSlice()); + arraylist.items.len = 0; + return arraylist; + } +}; + +const threadlog = Output.scoped(.HTTPThread, true); +const WriteMessage = struct { + async_http_id: u32, + flags: packed struct(u8) { + is_tls: bool, + type: Type, + _: u5 = 0, + }, + + pub const Type = enum(u2) { + data = 0, + end = 1, + endChunked = 2, + }; +}; +const ShutdownMessage = struct { + async_http_id: u32, + is_tls: bool, +}; + +pub const LibdeflateState = struct { + decompressor: *bun.libdeflate.Decompressor = undefined, + shared_buffer: [512 * 1024]u8 = undefined, + + pub const new = bun.TrivialNew(@This()); +}; + +const request_body_send_stack_buffer_size = 32 * 1024; + +pub inline fn getRequestBodySendBuffer(this: *@This(), estimated_size: usize) RequestBodyBuffer { + if (estimated_size >= request_body_send_stack_buffer_size) { + if (this.lazy_request_body_buffer == null) { + log("Allocating HeapRequestBodyBuffer due to {d} bytes request body", .{estimated_size}); + return .{ + .heap = HeapRequestBodyBuffer.init(), + }; + } + + return .{ .heap = bun.take(&this.lazy_request_body_buffer).? }; + } + return .{ + .stack = std.heap.stackFallback(request_body_send_stack_buffer_size, bun.default_allocator), + }; +} + +pub fn deflater(this: *@This()) *LibdeflateState { + if (this.lazy_libdeflater == null) { + this.lazy_libdeflater = LibdeflateState.new(.{ + .decompressor = bun.libdeflate.Decompressor.alloc() orelse bun.outOfMemory(), + }); + } + + return this.lazy_libdeflater.?; +} + +fn onInitErrorNoop(err: InitError, opts: InitOpts) noreturn { + switch (err) { + error.LoadCAFile => { + if (!bun.sys.existsZ(opts.abs_ca_file_name)) { + Output.err("HTTPThread", "failed to find CA file: '{s}'", .{opts.abs_ca_file_name}); + } else { + Output.err("HTTPThread", "failed to load CA file: '{s}'", .{opts.abs_ca_file_name}); + } + }, + error.InvalidCAFile => { + Output.err("HTTPThread", "the CA file is invalid: '{s}'", .{opts.abs_ca_file_name}); + }, + error.InvalidCA => { + Output.err("HTTPThread", "the provided CA is invalid", .{}); + }, + error.FailedToOpenSocket => { + Output.errGeneric("failed to start HTTP client thread", .{}); + }, + } + Global.crash(); +} + +pub const InitOpts = struct { + ca: []stringZ = &.{}, + abs_ca_file_name: stringZ = &.{}, + for_install: bool = false, + + onInitError: *const fn (err: InitError, opts: InitOpts) noreturn = &onInitErrorNoop, +}; + +fn initOnce(opts: *const InitOpts) void { + bun.http.http_thread = .{ + .loop = undefined, + .http_context = .{ + .us_socket_context = undefined, + .pending_sockets = NewHTTPContext(false).PooledSocketHiveAllocator.empty, + }, + .https_context = .{ + .us_socket_context = undefined, + .pending_sockets = NewHTTPContext(true).PooledSocketHiveAllocator.empty, + }, + .timer = std.time.Timer.start() catch unreachable, + }; + bun.libdeflate.load(); + const thread = std.Thread.spawn( + .{ + .stack_size = bun.default_thread_stack_size, + }, + onStart, + .{opts.*}, + ) catch |err| Output.panic("Failed to start HTTP Client thread: {s}", .{@errorName(err)}); + thread.detach(); +} +var init_once = bun.once(initOnce); + +pub fn init(opts: *const InitOpts) void { + init_once.call(.{opts}); +} + +pub fn onStart(opts: InitOpts) void { + Output.Source.configureNamedThread("HTTP Client"); + bun.http.default_arena = Arena.init() catch unreachable; + bun.http.default_allocator = bun.http.default_arena.allocator(); + + const loop = bun.JSC.MiniEventLoop.initGlobal(null); + + if (Environment.isWindows) { + _ = std.process.getenvW(comptime bun.strings.w("SystemRoot")) orelse { + bun.Output.errGeneric("The %SystemRoot% environment variable is not set. Bun needs this set in order for network requests to work.", .{}); + Global.crash(); + }; + } + + bun.http.http_thread.loop = loop; + bun.http.http_thread.http_context.init(); + bun.http.http_thread.https_context.initWithThreadOpts(&opts) catch |err| opts.onInitError(err, opts); + bun.http.http_thread.has_awoken.store(true, .monotonic); + bun.http.http_thread.processEvents(); +} + +pub fn connect(this: *@This(), client: *HTTPClient, comptime is_ssl: bool) !NewHTTPContext(is_ssl).HTTPSocket { + if (client.unix_socket_path.length() > 0) { + return try this.context(is_ssl).connectSocket(client, client.unix_socket_path.slice()); + } + + if (comptime is_ssl) { + const needs_own_context = client.tls_props != null and client.tls_props.?.requires_custom_request_ctx; + if (needs_own_context) { + var requested_config = client.tls_props.?; + for (custom_ssl_context_map.keys()) |other_config| { + if (requested_config.isSame(other_config)) { + // we free the callers config since we have a existing one + if (requested_config != client.tls_props) { + requested_config.deinit(); + bun.default_allocator.destroy(requested_config); + } + client.tls_props = other_config; + if (client.http_proxy) |url| { + return try custom_ssl_context_map.get(other_config).?.connect(client, url.hostname, url.getPortAuto()); + } else { + return try custom_ssl_context_map.get(other_config).?.connect(client, client.url.hostname, client.url.getPortAuto()); + } + } + } + // we need the config so dont free it + var custom_context = try bun.default_allocator.create(NewHTTPContext(is_ssl)); + custom_context.initWithClientConfig(client) catch |err| { + client.tls_props = null; + + requested_config.deinit(); + bun.default_allocator.destroy(requested_config); + bun.default_allocator.destroy(custom_context); + + // TODO: these error names reach js. figure out how they should be handled + return switch (err) { + error.FailedToOpenSocket => |e| e, + error.InvalidCA => error.FailedToOpenSocket, + error.InvalidCAFile => error.FailedToOpenSocket, + error.LoadCAFile => error.FailedToOpenSocket, + }; + }; + try custom_ssl_context_map.put(requested_config, custom_context); + // We might deinit the socket context, so we disable keepalive to make sure we don't + // free it while in use. + client.flags.disable_keepalive = true; + if (client.http_proxy) |url| { + // https://github.com/oven-sh/bun/issues/11343 + if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http")) { + return try this.context(is_ssl).connect(client, url.hostname, url.getPortAuto()); + } + return error.UnsupportedProxyProtocol; + } + return try custom_context.connect(client, client.url.hostname, client.url.getPortAuto()); + } + } + if (client.http_proxy) |url| { + if (url.href.len > 0) { + // https://github.com/oven-sh/bun/issues/11343 + if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http")) { + return try this.context(is_ssl).connect(client, url.hostname, url.getPortAuto()); + } + return error.UnsupportedProxyProtocol; + } + } + return try this.context(is_ssl).connect(client, client.url.hostname, client.url.getPortAuto()); +} + +pub fn context(this: *@This(), comptime is_ssl: bool) *NewHTTPContext(is_ssl) { + return if (is_ssl) &this.https_context else &this.http_context; +} + +fn drainEvents(this: *@This()) void { + { + this.queued_shutdowns_lock.lock(); + defer this.queued_shutdowns_lock.unlock(); + for (this.queued_shutdowns.items) |http| { + if (bun.http.socket_async_http_abort_tracker.fetchSwapRemove(http.async_http_id)) |socket_ptr| { + if (http.is_tls) { + const socket = uws.SocketTLS.fromAny(socket_ptr.value); + // do a fast shutdown here since we are aborting and we dont want to wait for the close_notify from the other side + socket.close(.failure); + } else { + const socket = uws.SocketTCP.fromAny(socket_ptr.value); + socket.close(.failure); + } + } + } + this.queued_shutdowns.clearRetainingCapacity(); + } + { + this.queued_writes_lock.lock(); + defer this.queued_writes_lock.unlock(); + for (this.queued_writes.items) |write| { + const flags = write.flags; + const messageType = flags.type; + const ended = messageType == .end or messageType == .endChunked; + + if (bun.http.socket_async_http_abort_tracker.get(write.async_http_id)) |socket_ptr| { + switch (flags.is_tls) { + inline true, false => |is_tls| { + const socket = uws.NewSocketHandler(is_tls).fromAny(socket_ptr); + if (socket.isClosed() or socket.isShutdown()) { + continue; + } + const tagged = NewHTTPContext(is_tls).getTaggedFromSocket(socket); + if (tagged.get(HTTPClient)) |client| { + if (client.state.original_request_body == .stream) { + var stream = &client.state.original_request_body.stream; + stream.ended = ended; + if (messageType == .endChunked) { + // only send the 0-length chunk if the request body is chunked + client.writeToStream(is_tls, socket, bun.http.end_of_chunked_http1_1_encoding_response_body); + } else { + client.flushStream(is_tls, socket); + } + } + } + }, + } + } + } + this.queued_writes.clearRetainingCapacity(); + } + + while (this.queued_proxy_deref.pop()) |http| { + http.deref(); + } + + var count: usize = 0; + var active = AsyncHTTP.active_requests_count.load(.monotonic); + const max = AsyncHTTP.max_simultaneous_requests.load(.monotonic); + if (active >= max) return; + defer { + if (comptime Environment.allow_assert) { + if (count > 0) + log("Processed {d} tasks\n", .{count}); + } + } + + while (this.queued_tasks.pop()) |http| { + var cloned = bun.http.ThreadlocalAsyncHTTP.new(.{ + .async_http = http.*, + }); + cloned.async_http.real = http; + cloned.async_http.onStart(); + if (comptime Environment.allow_assert) { + count += 1; + } + + active += 1; + if (active >= max) break; + } +} + +fn processEvents(this: *@This()) noreturn { + if (comptime Environment.isPosix) { + this.loop.loop.num_polls = @max(2, this.loop.loop.num_polls); + } else if (comptime Environment.isWindows) { + this.loop.loop.inc(); + } else { + @compileError("TODO:"); + } + + while (true) { + this.drainEvents(); + + var start_time: i128 = 0; + if (comptime Environment.isDebug) { + start_time = std.time.nanoTimestamp(); + } + Output.flush(); + + this.loop.loop.inc(); + this.loop.loop.tick(); + this.loop.loop.dec(); + + // this.loop.run(); + if (comptime Environment.isDebug) { + const end = std.time.nanoTimestamp(); + threadlog("Waited {any}\n", .{std.fmt.fmtDurationSigned(@as(i64, @truncate(end - start_time)))}); + Output.flush(); + } + } +} + +pub fn scheduleShutdown(this: *@This(), http: *AsyncHTTP) void { + { + this.queued_shutdowns_lock.lock(); + defer this.queued_shutdowns_lock.unlock(); + this.queued_shutdowns.append(bun.default_allocator, .{ + .async_http_id = http.async_http_id, + .is_tls = http.client.isHTTPS(), + }) catch bun.outOfMemory(); + } + if (this.has_awoken.load(.monotonic)) + this.loop.loop.wakeup(); +} + +pub fn scheduleRequestWrite(this: *@This(), http: *AsyncHTTP, messageType: WriteMessage.Type) void { + { + this.queued_writes_lock.lock(); + defer this.queued_writes_lock.unlock(); + this.queued_writes.append(bun.default_allocator, .{ + .async_http_id = http.async_http_id, + .flags = .{ + .is_tls = http.client.isHTTPS(), + .type = messageType, + }, + }) catch bun.outOfMemory(); + } + if (this.has_awoken.load(.monotonic)) + this.loop.loop.wakeup(); +} + +pub fn scheduleProxyDeref(this: *@This(), proxy: *ProxyTunnel) void { + // this is always called on the http thread + { + this.queued_proxy_deref.append(bun.default_allocator, proxy) catch bun.outOfMemory(); + } + if (this.has_awoken.load(.monotonic)) + this.loop.loop.wakeup(); +} + +pub fn wakeup(this: *@This()) void { + if (this.has_awoken.load(.monotonic)) + this.loop.loop.wakeup(); +} + +pub fn schedule(this: *@This(), batch: Batch) void { + if (batch.len == 0) + return; + + { + var batch_ = batch; + while (batch_.pop()) |task| { + const http: *AsyncHTTP = @fieldParentPtr("task", task); + this.queued_tasks.push(http); + } + } + + if (this.has_awoken.load(.monotonic)) + this.loop.loop.wakeup(); +} + +const std = @import("std"); + +const bun = @import("bun"); +const Output = bun.Output; +const Environment = bun.Environment; +const Global = bun.Global; +const uws = bun.uws; +const strings = bun.strings; +const stringZ = bun.stringZ; +const JSC = bun.JSC; +const NewHTTPContext = bun.http.NewHTTPContext; +const UnboundedQueue = @import("../bun.js/unbounded_queue.zig").UnboundedQueue; +const AsyncHTTP = bun.http.AsyncHTTP; +pub const Queue = UnboundedQueue(AsyncHTTP, .next); + +const HTTPClient = bun.http; +const ProxyTunnel = @import("./ProxyTunnel.zig"); +const InitError = HTTPClient.InitError; +const Batch = bun.ThreadPool.Batch; +const Arena = @import("../allocators/mimalloc_arena.zig").Arena; +const SSLConfig = @import("../bun.js/api/server.zig").ServerConfig.SSLConfig; +const log = Output.scoped(.HTTPThread, false); diff --git a/src/http/header_builder.zig b/src/http/HeaderBuilder.zig similarity index 100% rename from src/http/header_builder.zig rename to src/http/HeaderBuilder.zig diff --git a/src/http/Headers.zig b/src/http/Headers.zig new file mode 100644 index 0000000000..fc6d6072bd --- /dev/null +++ b/src/http/Headers.zig @@ -0,0 +1,182 @@ +const Headers = @This(); +pub const Entry = struct { + name: Api.StringPointer, + value: Api.StringPointer, + + pub const List = bun.MultiArrayList(Entry); +}; + +entries: Entry.List = .{}, +buf: std.ArrayListUnmanaged(u8) = .{}, +allocator: std.mem.Allocator, + +pub fn memoryCost(this: *const Headers) usize { + return this.buf.items.len + this.entries.memoryCost(); +} + +pub fn clone(this: *Headers) !Headers { + return Headers{ + .entries = try this.entries.clone(this.allocator), + .buf = try this.buf.clone(this.allocator), + .allocator = this.allocator, + }; +} + +pub fn get(this: *const Headers, name: []const u8) ?[]const u8 { + const entries = this.entries.slice(); + const names = entries.items(.name); + const values = entries.items(.value); + for (names, 0..) |name_ptr, i| { + if (bun.strings.eqlCaseInsensitiveASCII(this.asStr(name_ptr), name, true)) { + return this.asStr(values[i]); + } + } + + return null; +} + +pub fn append(this: *Headers, name: []const u8, value: []const u8) !void { + var offset: u32 = @truncate(this.buf.items.len); + try this.buf.ensureUnusedCapacity(this.allocator, name.len + value.len); + const name_ptr = Api.StringPointer{ + .offset = offset, + .length = @truncate(name.len), + }; + this.buf.appendSliceAssumeCapacity(name); + offset = @truncate(this.buf.items.len); + this.buf.appendSliceAssumeCapacity(value); + + const value_ptr = Api.StringPointer{ + .offset = offset, + .length = @truncate(value.len), + }; + try this.entries.append(this.allocator, .{ + .name = name_ptr, + .value = value_ptr, + }); +} + +pub fn deinit(this: *Headers) void { + this.entries.deinit(this.allocator); + this.buf.clearAndFree(this.allocator); +} +pub fn getContentType(this: *const Headers) ?[]const u8 { + if (this.entries.len == 0 or this.buf.items.len == 0) { + return null; + } + const header_entries = this.entries.slice(); + const header_names = header_entries.items(.name); + const header_values = header_entries.items(.value); + + for (header_names, 0..header_names.len) |name, i| { + if (bun.strings.eqlCaseInsensitiveASCII(this.asStr(name), "content-type", true)) { + return this.asStr(header_values[i]); + } + } + return null; +} +pub fn asStr(this: *const Headers, ptr: Api.StringPointer) []const u8 { + return if (ptr.offset + ptr.length <= this.buf.items.len) + this.buf.items[ptr.offset..][0..ptr.length] + else + ""; +} + +pub const Options = struct { + body: ?*const Blob.Any = null, +}; + +pub fn fromPicoHttpHeaders(headers: []const picohttp.Header, allocator: std.mem.Allocator) !Headers { + const header_count = headers.len; + var result = Headers{ + .entries = .{}, + .buf = .{}, + .allocator = allocator, + }; + + var buf_len: usize = 0; + for (headers) |header| { + buf_len += header.name.len + header.value.len; + } + result.entries.ensureTotalCapacity(allocator, header_count) catch bun.outOfMemory(); + result.entries.len = headers.len; + result.buf.ensureTotalCapacityPrecise(allocator, buf_len) catch bun.outOfMemory(); + result.buf.items.len = buf_len; + var offset: u32 = 0; + for (headers, 0..headers.len) |header, i| { + const name_offset = offset; + bun.copy(u8, result.buf.items[offset..][0..header.name.len], header.name); + offset += @truncate(header.name.len); + const value_offset = offset; + bun.copy(u8, result.buf.items[offset..][0..header.value.len], header.value); + offset += @truncate(header.value.len); + + result.entries.set(i, .{ + .name = .{ + .offset = name_offset, + .length = @truncate(header.name.len), + }, + .value = .{ + .offset = value_offset, + .length = @truncate(header.value.len), + }, + }); + } + return result; +} + +pub fn from(fetch_headers_ref: ?*FetchHeaders, allocator: std.mem.Allocator, options: Options) !Headers { + var header_count: u32 = 0; + var buf_len: u32 = 0; + if (fetch_headers_ref) |headers_ref| + headers_ref.count(&header_count, &buf_len); + var headers = Headers{ + .entries = .{}, + .buf = .{}, + .allocator = allocator, + }; + const buf_len_before_content_type = buf_len; + const needs_content_type = brk: { + if (options.body) |body| { + if (body.hasContentTypeFromUser() and (fetch_headers_ref == null or !fetch_headers_ref.?.fastHas(.ContentType))) { + header_count += 1; + buf_len += @as(u32, @truncate(body.contentType().len + "Content-Type".len)); + break :brk true; + } + } + break :brk false; + }; + headers.entries.ensureTotalCapacity(allocator, header_count) catch bun.outOfMemory(); + headers.entries.len = header_count; + headers.buf.ensureTotalCapacityPrecise(allocator, buf_len) catch bun.outOfMemory(); + headers.buf.items.len = buf_len; + var sliced = headers.entries.slice(); + var names = sliced.items(.name); + var values = sliced.items(.value); + if (fetch_headers_ref) |headers_ref| + headers_ref.copyTo(names.ptr, values.ptr, headers.buf.items.ptr); + + // TODO: maybe we should send Content-Type header first instead of last? + if (needs_content_type) { + bun.copy(u8, headers.buf.items[buf_len_before_content_type..], "Content-Type"); + names[header_count - 1] = .{ + .offset = buf_len_before_content_type, + .length = "Content-Type".len, + }; + + bun.copy(u8, headers.buf.items[buf_len_before_content_type + "Content-Type".len ..], options.body.?.contentType()); + values[header_count - 1] = .{ + .offset = buf_len_before_content_type + @as(u32, "Content-Type".len), + .length = @as(u32, @truncate(options.body.?.contentType().len)), + }; + } + + return headers; +} + +const Api = @import("../api/schema.zig").Api; +const std = @import("std"); +const bun = @import("bun"); +const picohttp = bun.picohttp; +const Blob = bun.webcore.Blob; +const FetchHeaders = bun.webcore.FetchHeaders; diff --git a/src/http/InitError.zig b/src/http/InitError.zig new file mode 100644 index 0000000000..4ef73a3064 --- /dev/null +++ b/src/http/InitError.zig @@ -0,0 +1,6 @@ +pub const InitError = error{ + FailedToOpenSocket, + LoadCAFile, + InvalidCAFile, + InvalidCA, +}; diff --git a/src/http/InternalState.zig b/src/http/InternalState.zig new file mode 100644 index 0000000000..f044e9da1c --- /dev/null +++ b/src/http/InternalState.zig @@ -0,0 +1,250 @@ +const InternalState = @This(); +// TODO: reduce the size of this struct +// Many of these fields can be moved to a packed struct and use less space + +response_message_buffer: MutableString = undefined, +/// pending response is the temporary storage for the response headers, url and status code +/// this uses shared_response_headers_buf to store the headers +/// this will be turned null once the metadata is cloned +pending_response: ?picohttp.Response = null, + +/// This is the cloned metadata containing the response headers, url and status code after the .headers phase are received +/// will be turned null once returned to the user (the ownership is transferred to the user) +/// this can happen after await fetch(...) and the body can continue streaming when this is already null +/// the user will receive only chunks of the body stored in body_out_str +cloned_metadata: ?HTTPResponseMetadata = null, +flags: InternalStateFlags = InternalStateFlags{}, + +transfer_encoding: Encoding = Encoding.identity, +encoding: Encoding = Encoding.identity, +content_encoding_i: u8 = std.math.maxInt(u8), +chunked_decoder: picohttp.phr_chunked_decoder = .{}, +decompressor: Decompressor = .{ .none = {} }, +stage: Stage = Stage.pending, +/// This is owned by the user and should not be freed here +body_out_str: ?*MutableString = null, +compressed_body: MutableString = undefined, +content_length: ?usize = null, +total_body_received: usize = 0, +request_body: []const u8 = "", +original_request_body: HTTPRequestBody = .{ .bytes = "" }, +request_sent_len: usize = 0, +fail: ?anyerror = null, +request_stage: HTTPStage = .pending, +response_stage: HTTPStage = .pending, +certificate_info: ?CertificateInfo = null, + +pub const InternalStateFlags = packed struct(u8) { + allow_keepalive: bool = true, + received_last_chunk: bool = false, + did_set_content_encoding: bool = false, + is_redirect_pending: bool = false, + is_libdeflate_fast_path_disabled: bool = false, + resend_request_body_on_redirect: bool = false, + _padding: u2 = 0, +}; + +pub fn init(body: HTTPRequestBody, body_out_str: *MutableString) InternalState { + return .{ + .original_request_body = body, + .request_body = if (body == .bytes) body.bytes else "", + .compressed_body = MutableString{ .allocator = bun.http.default_allocator, .list = .{} }, + .response_message_buffer = MutableString{ .allocator = bun.http.default_allocator, .list = .{} }, + .body_out_str = body_out_str, + .stage = Stage.pending, + .pending_response = null, + }; +} + +pub fn isChunkedEncoding(this: *InternalState) bool { + return this.transfer_encoding == Encoding.chunked; +} + +pub fn reset(this: *InternalState, allocator: std.mem.Allocator) void { + this.compressed_body.deinit(); + this.response_message_buffer.deinit(); + + const body_msg = this.body_out_str; + if (body_msg) |body| body.reset(); + this.decompressor.deinit(); + + // just in case we check and free to avoid leaks + if (this.cloned_metadata != null) { + this.cloned_metadata.?.deinit(allocator); + this.cloned_metadata = null; + } + + // if exists we own this info + if (this.certificate_info) |info| { + this.certificate_info = null; + info.deinit(bun.default_allocator); + } + + this.original_request_body.deinit(); + this.* = .{ + .body_out_str = body_msg, + .compressed_body = MutableString{ .allocator = bun.http.default_allocator, .list = .{} }, + .response_message_buffer = MutableString{ .allocator = bun.http.default_allocator, .list = .{} }, + .original_request_body = .{ .bytes = "" }, + .request_body = "", + .certificate_info = null, + .flags = .{}, + .total_body_received = 0, + }; +} + +pub fn getBodyBuffer(this: *InternalState) *MutableString { + if (this.encoding.isCompressed()) { + return &this.compressed_body; + } + + return this.body_out_str.?; +} + +pub fn isDone(this: *InternalState) bool { + if (this.isChunkedEncoding()) { + return this.flags.received_last_chunk; + } + + if (this.content_length) |content_length| { + return this.total_body_received >= content_length; + } + + // Content-Type: text/event-stream we should be done only when Close/End/Timeout connection + return this.flags.received_last_chunk; +} + +pub fn decompressBytes(this: *InternalState, buffer: []const u8, body_out_str: *MutableString, is_final_chunk: bool) !void { + defer this.compressed_body.reset(); + var gzip_timer: std.time.Timer = undefined; + + if (bun.http.extremely_verbose) + gzip_timer = std.time.Timer.start() catch @panic("Timer failure"); + + var still_needs_to_decompress = true; + + if (FeatureFlags.isLibdeflateEnabled()) { + // Fast-path: use libdeflate + if (is_final_chunk and !this.flags.is_libdeflate_fast_path_disabled and this.encoding.canUseLibDeflate() and this.isDone()) libdeflate: { + this.flags.is_libdeflate_fast_path_disabled = true; + + log("Decompressing {d} bytes with libdeflate\n", .{buffer.len}); + var deflater = bun.http.http_thread.deflater(); + + // gzip stores the size of the uncompressed data in the last 4 bytes of the stream + // But it's only valid if the stream is less than 4.7 GB, since it's 4 bytes. + // If we know that the stream is going to be larger than our + // pre-allocated buffer, then let's dynamically allocate the exact + // size. + if (this.encoding == Encoding.gzip and buffer.len > 16 and buffer.len < 1024 * 1024 * 1024) { + const estimated_size: u32 = @bitCast(buffer[buffer.len - 4 ..][0..4].*); + // Since this is arbtirary input from the internet, let's set an upper bound of 32 MB for the allocation size. + if (estimated_size > deflater.shared_buffer.len and estimated_size < 32 * 1024 * 1024) { + try body_out_str.list.ensureTotalCapacityPrecise(body_out_str.allocator, estimated_size); + const result = deflater.decompressor.decompress(buffer, body_out_str.list.allocatedSlice(), .gzip); + + if (result.status == .success) { + body_out_str.list.items.len = result.written; + still_needs_to_decompress = false; + } + + break :libdeflate; + } + } + + const result = deflater.decompressor.decompress(buffer, &deflater.shared_buffer, switch (this.encoding) { + .gzip => .gzip, + .deflate => .deflate, + else => unreachable, + }); + + if (result.status == .success) { + try body_out_str.list.ensureTotalCapacityPrecise(body_out_str.allocator, result.written); + body_out_str.list.appendSliceAssumeCapacity(deflater.shared_buffer[0..result.written]); + still_needs_to_decompress = false; + } + } + } + + // Slow path, or brotli: use the .decompressor + if (still_needs_to_decompress) { + log("Decompressing {d} bytes\n", .{buffer.len}); + if (body_out_str.list.capacity == 0) { + const min = @min(@ceil(@as(f64, @floatFromInt(buffer.len)) * 1.5), @as(f64, 1024 * 1024 * 2)); + try body_out_str.growBy(@max(@as(usize, @intFromFloat(min)), 32)); + } + + try this.decompressor.updateBuffers(this.encoding, buffer, body_out_str); + + this.decompressor.readAll(this.isDone()) catch |err| { + if (this.isDone() or error.ShortRead != err) { + Output.prettyErrorln("Decompression error: {s}", .{bun.asByteSlice(@errorName(err))}); + Output.flush(); + return err; + } + }; + } + + if (bun.http.extremely_verbose) + this.gzip_elapsed = gzip_timer.read(); +} + +pub fn decompress(this: *InternalState, buffer: MutableString, body_out_str: *MutableString, is_final_chunk: bool) !void { + try this.decompressBytes(buffer.list.items, body_out_str, is_final_chunk); +} + +pub fn processBodyBuffer(this: *InternalState, buffer: MutableString, is_final_chunk: bool) !bool { + if (this.flags.is_redirect_pending) return false; + + var body_out_str = this.body_out_str.?; + + switch (this.encoding) { + Encoding.brotli, Encoding.gzip, Encoding.deflate, Encoding.zstd => { + try this.decompress(buffer, body_out_str, is_final_chunk); + }, + else => { + if (!body_out_str.owns(buffer.list.items)) { + body_out_str.append(buffer.list.items) catch |err| { + Output.prettyErrorln("Failed to append to body buffer: {s}", .{bun.asByteSlice(@errorName(err))}); + Output.flush(); + return err; + }; + } + }, + } + + return this.body_out_str.?.list.items.len > 0; +} + +const std = @import("std"); +const bun = @import("bun"); +const MutableString = bun.MutableString; +const picohttp = bun.picohttp; +const Output = bun.Output; +const FeatureFlags = bun.FeatureFlags; +const HTTPClient = bun.http; +const HTTPResponseMetadata = HTTPClient.HTTPResponseMetadata; +const CertificateInfo = HTTPClient.CertificateInfo; +const Encoding = HTTPClient.Encoding; +const Decompressor = HTTPClient.Decompressor; +const HTTPRequestBody = HTTPClient.HTTPRequestBody; +const log = Output.scoped(.HTTPInternalState, true); + +const HTTPStage = enum { + pending, + headers, + body, + body_chunk, + fail, + done, + proxy_handshake, + proxy_headers, + proxy_body, +}; + +const Stage = enum(u8) { + pending, + connect, + done, + fail, +}; diff --git a/src/http/method.zig b/src/http/Method.zig similarity index 100% rename from src/http/method.zig rename to src/http/Method.zig diff --git a/src/http/mime_type.zig b/src/http/MimeType.zig similarity index 100% rename from src/http/mime_type.zig rename to src/http/MimeType.zig diff --git a/src/http/ProxyTunnel.zig b/src/http/ProxyTunnel.zig new file mode 100644 index 0000000000..fdf0adb2a8 --- /dev/null +++ b/src/http/ProxyTunnel.zig @@ -0,0 +1,345 @@ +const ProxyTunnel = @This(); +const RefCount = bun.ptr.RefCount(@This(), "ref_count", ProxyTunnel.deinit, .{}); +pub const ref = ProxyTunnel.RefCount.ref; +pub const deref = ProxyTunnel.RefCount.deref; + +wrapper: ?ProxyTunnelWrapper = null, +shutdown_err: anyerror = error.ConnectionClosed, +// active socket is the socket that is currently being used +socket: union(enum) { + tcp: NewHTTPContext(false).HTTPSocket, + ssl: NewHTTPContext(true).HTTPSocket, + none: void, +} = .{ .none = {} }, +write_buffer: bun.io.StreamBuffer = .{}, +ref_count: RefCount, + +const ProxyTunnelWrapper = SSLWrapper(*HTTPClient); + +fn onOpen(this: *HTTPClient) void { + log("ProxyTunnel onOpen", .{}); + this.state.response_stage = .proxy_handshake; + this.state.request_stage = .proxy_handshake; + if (this.proxy_tunnel) |proxy| { + proxy.ref(); + defer proxy.deref(); + if (proxy.wrapper) |*wrapper| { + var ssl_ptr = wrapper.ssl orelse return; + const _hostname = this.hostname orelse this.url.hostname; + + var hostname: [:0]const u8 = ""; + var hostname_needs_free = false; + if (!strings.isIPAddress(_hostname)) { + if (_hostname.len < bun.http.temp_hostname.len) { + @memcpy(bun.http.temp_hostname[0.._hostname.len], _hostname); + bun.http.temp_hostname[_hostname.len] = 0; + hostname = bun.http.temp_hostname[0.._hostname.len :0]; + } else { + hostname = bun.default_allocator.dupeZ(u8, _hostname) catch unreachable; + hostname_needs_free = true; + } + } + + defer if (hostname_needs_free) bun.default_allocator.free(hostname); + ssl_ptr.configureHTTPClient(hostname); + } + } +} + +fn onData(this: *HTTPClient, decoded_data: []const u8) void { + if (decoded_data.len == 0) return; + log("ProxyTunnel onData decoded {}", .{decoded_data.len}); + if (this.proxy_tunnel) |proxy| { + proxy.ref(); + defer proxy.deref(); + switch (this.state.response_stage) { + .body => { + log("ProxyTunnel onData body", .{}); + if (decoded_data.len == 0) return; + const report_progress = this.handleResponseBody(decoded_data, false) catch |err| { + proxy.close(err); + return; + }; + + if (report_progress) { + switch (proxy.socket) { + .ssl => |socket| { + this.progressUpdate(true, &bun.http.http_thread.https_context, socket); + }, + .tcp => |socket| { + this.progressUpdate(false, &bun.http.http_thread.http_context, socket); + }, + .none => {}, + } + return; + } + }, + .body_chunk => { + log("ProxyTunnel onData body_chunk", .{}); + if (decoded_data.len == 0) return; + const report_progress = this.handleResponseBodyChunkedEncoding(decoded_data) catch |err| { + proxy.close(err); + return; + }; + + if (report_progress) { + switch (proxy.socket) { + .ssl => |socket| { + this.progressUpdate(true, &bun.http.http_thread.https_context, socket); + }, + .tcp => |socket| { + this.progressUpdate(false, &bun.http.http_thread.http_context, socket); + }, + .none => {}, + } + return; + } + }, + .proxy_headers => { + log("ProxyTunnel onData proxy_headers", .{}); + switch (proxy.socket) { + .ssl => |socket| { + this.handleOnDataHeaders(true, decoded_data, &bun.http.http_thread.https_context, socket); + }, + .tcp => |socket| { + this.handleOnDataHeaders(false, decoded_data, &bun.http.http_thread.http_context, socket); + }, + .none => {}, + } + }, + else => { + log("ProxyTunnel onData unexpected data", .{}); + this.state.pending_response = null; + proxy.close(error.UnexpectedData); + }, + } + } +} + +fn onHandshake(this: *HTTPClient, handshake_success: bool, ssl_error: uws.us_bun_verify_error_t) void { + if (this.proxy_tunnel) |proxy| { + log("ProxyTunnel onHandshake", .{}); + proxy.ref(); + defer proxy.deref(); + this.state.response_stage = .proxy_headers; + this.state.request_stage = .proxy_headers; + this.state.request_sent_len = 0; + const handshake_error = HTTPCertError{ + .error_no = ssl_error.error_no, + .code = if (ssl_error.code == null) "" else ssl_error.code[0..bun.len(ssl_error.code) :0], + .reason = if (ssl_error.code == null) "" else ssl_error.reason[0..bun.len(ssl_error.reason) :0], + }; + if (handshake_success) { + log("ProxyTunnel onHandshake success", .{}); + // handshake completed but we may have ssl errors + this.flags.did_have_handshaking_error = handshake_error.error_no != 0; + if (this.flags.reject_unauthorized) { + // only reject the connection if reject_unauthorized == true + if (this.flags.did_have_handshaking_error) { + proxy.close(BoringSSL.getCertErrorFromNo(handshake_error.error_no)); + return; + } + + // if checkServerIdentity returns false, we dont call open this means that the connection was rejected + bun.assert(proxy.wrapper != null); + const ssl_ptr = proxy.wrapper.?.ssl orelse return; + + switch (proxy.socket) { + .ssl => |socket| { + if (!this.checkServerIdentity(true, socket, handshake_error, ssl_ptr, false)) { + log("ProxyTunnel onHandshake checkServerIdentity failed", .{}); + this.flags.did_have_handshaking_error = true; + + this.unregisterAbortTracker(); + return; + } + }, + .tcp => |socket| { + if (!this.checkServerIdentity(false, socket, handshake_error, ssl_ptr, false)) { + log("ProxyTunnel onHandshake checkServerIdentity failed", .{}); + this.flags.did_have_handshaking_error = true; + this.unregisterAbortTracker(); + return; + } + }, + .none => {}, + } + } + + switch (proxy.socket) { + .ssl => |socket| { + this.onWritable(true, true, socket); + }, + .tcp => |socket| { + this.onWritable(true, false, socket); + }, + .none => {}, + } + } else { + log("ProxyTunnel onHandshake failed", .{}); + // if we are here is because server rejected us, and the error_no is the cause of this + // if we set reject_unauthorized == false this means the server requires custom CA aka NODE_EXTRA_CA_CERTS + if (this.flags.did_have_handshaking_error and handshake_error.error_no != 0) { + proxy.close(BoringSSL.getCertErrorFromNo(handshake_error.error_no)); + return; + } + // if handshake_success it self is false, this means that the connection was rejected + proxy.close(error.ConnectionRefused); + return; + } + } +} + +pub fn write(this: *HTTPClient, encoded_data: []const u8) void { + if (this.proxy_tunnel) |proxy| { + const written = switch (proxy.socket) { + .ssl => |socket| socket.write(encoded_data), + .tcp => |socket| socket.write(encoded_data), + .none => 0, + }; + const pending = encoded_data[@intCast(written)..]; + if (pending.len > 0) { + // lets flush when we are truly writable + proxy.write_buffer.write(pending) catch bun.outOfMemory(); + } + } +} + +fn onClose(this: *HTTPClient) void { + log("ProxyTunnel onClose {s}", .{if (this.proxy_tunnel == null) "tunnel is detached" else "tunnel exists"}); + if (this.proxy_tunnel) |proxy| { + proxy.ref(); + // defer the proxy deref the proxy tunnel may still be in use after triggering the close callback + defer bun.http.http_thread.scheduleProxyDeref(proxy); + const err = proxy.shutdown_err; + switch (proxy.socket) { + .ssl => |socket| { + this.closeAndFail(err, true, socket); + }, + .tcp => |socket| { + this.closeAndFail(err, false, socket); + }, + .none => {}, + } + proxy.detachSocket(); + } +} + +pub fn start(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket, ssl_options: JSC.API.ServerConfig.SSLConfig, start_payload: []const u8) void { + const proxy_tunnel = bun.new(ProxyTunnel, .{ + .ref_count = .init(), + }); + + var custom_options = ssl_options; + // we always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match + custom_options.reject_unauthorized = 0; + custom_options.request_cert = 1; + proxy_tunnel.wrapper = SSLWrapper(*HTTPClient).init(custom_options, true, .{ + .onOpen = ProxyTunnel.onOpen, + .onData = ProxyTunnel.onData, + .onHandshake = ProxyTunnel.onHandshake, + .onClose = ProxyTunnel.onClose, + .write = ProxyTunnel.write, + .ctx = this, + }) catch |err| { + if (err == error.OutOfMemory) { + bun.outOfMemory(); + } + + // invalid TLS Options + proxy_tunnel.detachAndDeref(); + this.closeAndFail(error.ConnectionRefused, is_ssl, socket); + return; + }; + this.proxy_tunnel = proxy_tunnel; + if (is_ssl) { + proxy_tunnel.socket = .{ .ssl = socket }; + } else { + proxy_tunnel.socket = .{ .tcp = socket }; + } + if (start_payload.len > 0) { + log("proxy tunnel start with payload", .{}); + proxy_tunnel.wrapper.?.startWithPayload(start_payload); + } else { + log("proxy tunnel start", .{}); + proxy_tunnel.wrapper.?.start(); + } +} + +pub fn close(this: *ProxyTunnel, err: anyerror) void { + this.shutdown_err = err; + this.shutdown(); +} + +pub fn shutdown(this: *ProxyTunnel) void { + if (this.wrapper) |*wrapper| { + // fast shutdown the connection + _ = wrapper.shutdown(true); + } +} + +pub fn onWritable(this: *ProxyTunnel, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket) void { + log("ProxyTunnel onWritable", .{}); + this.ref(); + defer this.deref(); + defer if (this.wrapper) |*wrapper| { + // Cycle to through the SSL state machine + _ = wrapper.flush(); + }; + + const encoded_data = this.write_buffer.slice(); + if (encoded_data.len == 0) { + return; + } + const written = socket.write(encoded_data); + if (written == encoded_data.len) { + this.write_buffer.reset(); + } else { + this.write_buffer.cursor += @intCast(written); + } +} + +pub fn receiveData(this: *ProxyTunnel, buf: []const u8) void { + this.ref(); + defer this.deref(); + if (this.wrapper) |*wrapper| { + wrapper.receiveData(buf); + } +} + +pub fn writeData(this: *ProxyTunnel, buf: []const u8) !usize { + if (this.wrapper) |*wrapper| { + return try wrapper.writeData(buf); + } + return error.ConnectionClosed; +} + +pub fn detachSocket(this: *ProxyTunnel) void { + this.socket = .{ .none = {} }; +} + +pub fn detachAndDeref(this: *ProxyTunnel) void { + this.detachSocket(); + this.deref(); +} + +fn deinit(this: *ProxyTunnel) void { + this.socket = .{ .none = {} }; + if (this.wrapper) |*wrapper| { + wrapper.deinit(); + this.wrapper = null; + } + this.write_buffer.deinit(); + bun.destroy(this); +} + +const bun = @import("bun"); +const strings = bun.strings; +const uws = bun.uws; +const BoringSSL = bun.BoringSSL.c; +const NewHTTPContext = bun.http.NewHTTPContext; +const HTTPClient = bun.http; +const JSC = bun.JSC; +const HTTPCertError = @import("./HTTPCertError.zig"); +const SSLWrapper = @import("../bun.js/api/bun/ssl_wrapper.zig").SSLWrapper; +const log = bun.Output.scoped(.http_proxy_tunnel, false); diff --git a/src/http/SendFile.zig b/src/http/SendFile.zig new file mode 100644 index 0000000000..63ca105a28 --- /dev/null +++ b/src/http/SendFile.zig @@ -0,0 +1,78 @@ +const SendFile = @This(); + +fd: bun.FileDescriptor, +remain: usize = 0, +offset: usize = 0, +content_size: usize = 0, + +pub fn isEligible(url: bun.URL) bool { + if (comptime Environment.isWindows or !FeatureFlags.streaming_file_uploads_for_http_client) { + return false; + } + return url.isHTTP() and url.href.len > 0; +} + +pub fn write( + this: *SendFile, + socket: NewHTTPContext(false).HTTPSocket, +) Status { + const adjusted_count_temporary = @min(@as(u64, this.remain), @as(u63, std.math.maxInt(u63))); + // TODO we should not need this int cast; improve the return type of `@min` + const adjusted_count = @as(u63, @intCast(adjusted_count_temporary)); + + if (Environment.isLinux) { + var signed_offset = @as(i64, @intCast(this.offset)); + const begin = this.offset; + const val = + // this does the syscall directly, without libc + std.os.linux.sendfile(socket.fd().cast(), this.fd.cast(), &signed_offset, this.remain); + this.offset = @as(u64, @intCast(signed_offset)); + + const errcode = bun.sys.getErrno(val); + + this.remain -|= @as(u64, @intCast(this.offset -| begin)); + + if (errcode != .SUCCESS or this.remain == 0 or val == 0) { + if (errcode == .SUCCESS) { + return .{ .done = {} }; + } + + return .{ .err = bun.errnoToZigErr(errcode) }; + } + } else if (Environment.isPosix) { + var sbytes: std.posix.off_t = adjusted_count; + const signed_offset = @as(i64, @bitCast(@as(u64, this.offset))); + const errcode = bun.sys.getErrno(std.c.sendfile( + this.fd.cast(), + socket.fd().cast(), + signed_offset, + &sbytes, + null, + 0, + )); + const wrote = @as(u64, @intCast(sbytes)); + this.offset +|= wrote; + this.remain -|= wrote; + if (errcode != .AGAIN or this.remain == 0 or sbytes == 0) { + if (errcode == .SUCCESS) { + return .{ .done = {} }; + } + + return .{ .err = bun.errnoToZigErr(errcode) }; + } + } + + return .{ .again = {} }; +} + +pub const Status = union(enum) { + done: void, + err: anyerror, + again: void, +}; + +const std = @import("std"); +const bun = @import("bun"); +const Environment = bun.Environment; +const FeatureFlags = bun.FeatureFlags; +const NewHTTPContext = bun.http.NewHTTPContext; diff --git a/src/http/Signals.zig b/src/http/Signals.zig new file mode 100644 index 0000000000..78531e7f41 --- /dev/null +++ b/src/http/Signals.zig @@ -0,0 +1,31 @@ +const Signals = @This(); + +header_progress: ?*std.atomic.Value(bool) = null, +body_streaming: ?*std.atomic.Value(bool) = null, +aborted: ?*std.atomic.Value(bool) = null, +cert_errors: ?*std.atomic.Value(bool) = null, +pub fn isEmpty(this: *const Signals) bool { + return this.aborted == null and this.body_streaming == null and this.header_progress == null and this.cert_errors == null; +} + +pub const Store = struct { + header_progress: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + body_streaming: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + aborted: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + cert_errors: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + pub fn to(this: *Store) Signals { + return .{ + .header_progress = &this.header_progress, + .body_streaming = &this.body_streaming, + .aborted = &this.aborted, + .cert_errors = &this.cert_errors, + }; + } +}; + +pub fn get(this: Signals, comptime field: std.meta.FieldEnum(Signals)) bool { + var ptr: *std.atomic.Value(bool) = @field(this, @tagName(field)) orelse return false; + return ptr.load(.monotonic); +} + +const std = @import("std"); diff --git a/src/http/ThreadSafeStreamBuffer.zig b/src/http/ThreadSafeStreamBuffer.zig new file mode 100644 index 0000000000..00cd279c12 --- /dev/null +++ b/src/http/ThreadSafeStreamBuffer.zig @@ -0,0 +1,61 @@ +const ThreadSafeStreamBuffer = @This(); + +buffer: bun.io.StreamBuffer = .{}, +mutex: bun.Mutex = .{}, +ref_count: StreamBufferRefCount = .initExactRefs(2), // 1 for main thread and 1 for http thread +// callback will be called passing the context for the http callback +// this is used to report when the buffer is drained and only if end chunk was not sent/reported +callback: ?Callback = null, + +const Callback = struct { + callback: *const fn (*anyopaque) void, + context: *anyopaque, + + pub fn init(comptime T: type, callback: *const fn (*T) void, context: *T) @This() { + return .{ .callback = @ptrCast(callback), .context = @ptrCast(context) }; + } + + pub fn call(this: @This()) void { + this.callback(this.context); + } +}; + +const StreamBufferRefCount = bun.ptr.ThreadSafeRefCount(@This(), "ref_count", ThreadSafeStreamBuffer.deinit, .{}); +pub const ref = StreamBufferRefCount.ref; +pub const deref = StreamBufferRefCount.deref; +pub const new = bun.TrivialNew(@This()); + +pub fn acquire(this: *ThreadSafeStreamBuffer) *bun.io.StreamBuffer { + this.mutex.lock(); + return &this.buffer; +} + +pub fn release(this: *ThreadSafeStreamBuffer) void { + this.mutex.unlock(); +} + +/// Should only be called in the main thread and before schedule the it to the http thread +pub fn setDrainCallback(this: *ThreadSafeStreamBuffer, comptime T: type, callback: *const fn (*T) void, context: *T) void { + this.callback = Callback.init(T, callback, context); +} + +pub fn clearDrainCallback(this: *ThreadSafeStreamBuffer) void { + this.callback = null; +} + +/// This is exclusively called from the http thread +/// Buffer should be acquired before calling this +pub fn reportDrain(this: *ThreadSafeStreamBuffer) void { + if (this.buffer.isEmpty()) { + if (this.callback) |callback| { + callback.call(); + } + } +} + +pub fn deinit(this: *ThreadSafeStreamBuffer) void { + this.buffer.deinit(); + bun.destroy(this); +} + +const bun = @import("bun"); diff --git a/src/http/url_path.zig b/src/http/URLPath.zig similarity index 100% rename from src/http/url_path.zig rename to src/http/URLPath.zig diff --git a/src/http/websocket_client.zig b/src/http/websocket_client.zig index b7f0a6f501..110b0aa5a9 100644 --- a/src/http/websocket_client.zig +++ b/src/http/websocket_client.zig @@ -710,7 +710,7 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { // fast path: no backpressure, no queue, just send the bytes. if (!this.hasBackpressure()) { // Do not set MSG_MORE, see https://github.com/oven-sh/bun/issues/4010 - const wrote = socket.write(bytes, false); + const wrote = socket.write(bytes); const expected = @as(c_int, @intCast(bytes.len)); if (wrote == expected) { return true; @@ -831,7 +831,7 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { if (this.tcp.isClosed()) { return false; } - const wrote = this.tcp.write(out_buf, false); + const wrote = this.tcp.write(out_buf); if (wrote < 0) { this.terminate(ErrorCode.failed_to_write); return false; diff --git a/src/http/websocket_client/WebSocketUpgradeClient.zig b/src/http/websocket_client/WebSocketUpgradeClient.zig index 4a02f6dea5..f52dfb2aa9 100644 --- a/src/http/websocket_client/WebSocketUpgradeClient.zig +++ b/src/http/websocket_client/WebSocketUpgradeClient.zig @@ -275,7 +275,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { } // Do not set MSG_MORE, see https://github.com/oven-sh/bun/issues/4010 - const wrote = socket.write(this.input_body_buf, false); + const wrote = socket.write(this.input_body_buf); if (wrote < 0) { this.terminate(ErrorCode.failed_to_write); return; @@ -539,7 +539,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { defer this.deref(); // Do not set MSG_MORE, see https://github.com/oven-sh/bun/issues/4010 - const wrote = socket.write(this.to_send, false); + const wrote = socket.write(this.to_send); if (wrote < 0) { this.terminate(ErrorCode.failed_to_write); return; diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 987ba63f00..d7986dab85 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -344,6 +344,7 @@ declare function $addEventListener(): TODO; declare function $appendFromJS(): TODO; declare function $argv(): TODO; declare function $assignToStream(): TODO; +declare function $assignStreamIntoResumableSink(): TODO; declare function $associatedReadableByteStreamController(): TODO; declare function $autoAllocateChunkSize(): TODO; declare function $backpressure(): TODO; diff --git a/src/js/builtins/ReadableStreamInternals.ts b/src/js/builtins/ReadableStreamInternals.ts index e5a9f9cf06..5cb7316330 100644 --- a/src/js/builtins/ReadableStreamInternals.ts +++ b/src/js/builtins/ReadableStreamInternals.ts @@ -758,6 +758,123 @@ export function assignToStream(stream, sink) { return $readStreamIntoSink(stream, sink, true); } +$linkTimeConstant; +export function assignStreamIntoResumableSink(stream, sink) { + const highWaterMark = $getByIdDirectPrivate(stream, "highWaterMark") || 0; + let error: Error | null = null; + let reading = false; + let closed = false; + let reader: ReadableStreamDefaultReader | undefined; + + function releaseReader() { + if (reader) { + try { + reader.releaseLock(); + } catch {} + reader = undefined; + } + sink = undefined; + if (stream) { + var streamState = $getByIdDirectPrivate(stream, "state"); + // make it easy for this to be GC'd + // but don't do property transitions + var readableStreamController = $getByIdDirectPrivate(stream, "readableStreamController"); + if (readableStreamController) { + if ($getByIdDirectPrivate(readableStreamController, "underlyingSource")) + $putByIdDirectPrivate(readableStreamController, "underlyingSource", null); + if ($getByIdDirectPrivate(readableStreamController, "controlledReadableStream")) + $putByIdDirectPrivate(readableStreamController, "controlledReadableStream", null); + + $putByIdDirectPrivate(stream, "readableStreamController", null); + if ($getByIdDirectPrivate(stream, "underlyingSource")) $putByIdDirectPrivate(stream, "underlyingSource", null); + readableStreamController = undefined; + } + + if (stream && !error && streamState !== $streamClosed && streamState !== $streamErrored) { + $readableStreamCloseIfPossible(stream); + } + stream = undefined; + } + } + function endSink(...args: any[]) { + try { + sink?.end(...args); + } catch {} // should never throw + releaseReader(); + } + + try { + // always call start even if reader throws + + sink.start({ highWaterMark }); + + reader = stream.getReader(); + + async function drainReaderIntoSink() { + if (error || closed || reading) return; + reading = true; + + try { + while (true) { + var { value, done } = await reader!.read(); + if (closed) break; + + if (done) { + closed = true; + // lets cover just in case we have a value when done is true + // this shouldn't happen but just in case + if (value) { + sink.write(value); + } + // clean end + return endSink(); + } + + if (value) { + // write returns false under backpressure + if (!sink.write(value)) { + break; + } + } + } + } catch (e: any) { + error = e; + closed = true; + try { + const prom = stream?.cancel(e); + if ($isPromise(prom)) { + $markPromiseAsHandled(prom); + } + } catch {} + // end with the error NT so we can simplify the flow to only listen to end + queueMicrotask(endSink.bind(null, e)); + } finally { + reading = false; + } + } + + function cancelStream(reason: Error | null) { + if (closed) return; + let wasClosed = closed; + closed = true; + if (stream && !error && !wasClosed && stream.$state !== $streamClosed) { + $readableStreamCancel(stream, reason); + } + releaseReader(); + } + // drain is called when the backpressure is release so we can continue draining + // cancel is called if closed or errored by the other side + sink.setHandlers(drainReaderIntoSink, cancelStream); + + drainReaderIntoSink(); + } catch (e: any) { + error = e; + closed = true; + // end with the error + queueMicrotask(endSink.bind(null, e)); + } +} + export async function readStreamIntoSink(stream: ReadableStream, sink, isNative) { var didClose = false; var didThrow = false; @@ -842,8 +959,8 @@ export async function readStreamIntoSink(stream: ReadableStream, sink, isNative) reader = undefined; } sink = undefined; - var streamState = $getByIdDirectPrivate(stream, "state"); if (stream) { + var streamState = $getByIdDirectPrivate(stream, "state"); // make it easy for this to be GC'd // but don't do property transitions var readableStreamController = $getByIdDirectPrivate(stream, "readableStreamController"); @@ -858,7 +975,7 @@ export async function readStreamIntoSink(stream: ReadableStream, sink, isNative) readableStreamController = undefined; } - if (!didThrow && streamState !== $streamClosed && streamState !== $streamErrored) { + if (stream && !didThrow && streamState !== $streamClosed && streamState !== $streamErrored) { $readableStreamCloseIfPossible(stream); } stream = undefined; @@ -1099,6 +1216,7 @@ export function onCloseDirectStream(reason) { export function onFlushDirectStream() { var stream = this.$controlledReadableStream; + if (!stream) return; var reader = $getByIdDirectPrivate(stream, "reader"); if (!reader || !$isReadableStreamDefaultReader(reader)) { return; diff --git a/src/router.zig b/src/router.zig index 5d2d8b37b4..b61930074c 100644 --- a/src/router.zig +++ b/src/router.zig @@ -20,7 +20,7 @@ const StoredFileDescriptorType = bun.StoredFileDescriptorType; const DirInfo = @import("./resolver/dir_info.zig"); const Fs = @import("./fs.zig"); const Options = @import("./options.zig"); -const URLPath = @import("./http/url_path.zig"); +const URLPath = @import("./http/URLPath.zig"); const PathnameScanner = @import("./url.zig").PathnameScanner; const CodepointIterator = @import("./string_immutable.zig").CodepointIterator; diff --git a/src/s3/client.zig b/src/s3/client.zig index 7f6eb6c76f..65feaa76dd 100644 --- a/src/s3/client.zig +++ b/src/s3/client.zig @@ -261,21 +261,30 @@ pub fn writableStream( ) bun.JSError!JSC.JSValue { const Wrapper = struct { pub fn callback(result: S3UploadResult, sink: *JSC.WebCore.NetworkSink) void { - if (sink.endPromise.hasValue()) { + if (sink.endPromise.hasValue() or sink.flushPromise.hasValue()) { const event_loop = sink.globalThis.bunVM().eventLoop(); event_loop.enter(); defer event_loop.exit(); switch (result) { .success => { - sink.endPromise.resolve(sink.globalThis, JSC.jsNumber(0)); + if (sink.flushPromise.hasValue()) { + sink.flushPromise.resolve(sink.globalThis, JSC.jsNumber(0)); + } + if (sink.endPromise.hasValue()) { + sink.endPromise.resolve(sink.globalThis, JSC.jsNumber(0)); + } }, .failure => |err| { + const js_err = err.toJS(sink.globalThis, sink.path()); + if (sink.flushPromise.hasValue()) { + sink.flushPromise.reject(sink.globalThis, js_err); + } + if (sink.endPromise.hasValue()) { + sink.endPromise.reject(sink.globalThis, js_err); + } if (!sink.done) { sink.abort(); - return; } - - sink.endPromise.reject(sink.globalThis, err.toJS(sink.globalThis, sink.path())); }, } } @@ -285,7 +294,7 @@ pub fn writableStream( const proxy_url = (proxy orelse ""); this.ref(); // ref the credentials const task = bun.new(MultiPartUpload, .{ - .ref_count = .init(), + .ref_count = .initExactRefs(2), // +1 for the stream .credentials = this, .path = bun.default_allocator.dupe(u8, path) catch bun.outOfMemory(), .proxy = if (proxy_url.len > 0) bun.default_allocator.dupe(u8, proxy_url) catch bun.outOfMemory() else "", @@ -301,16 +310,14 @@ pub fn writableStream( task.poll_ref.ref(task.vm); - task.ref(); // + 1 for the stream var response_stream = JSC.WebCore.NetworkSink.new(.{ - .task = .{ .s3_upload = task }, - .buffer = .{}, + .task = task, .globalThis = globalThis, - .encoded = false, - .endPromise = JSC.JSPromise.Strong.init(globalThis), + .highWaterMark = @truncate(options.partSize), }).toSink(); task.callback_context = @ptrCast(response_stream); + task.onWritable = @ptrCast(&JSC.WebCore.NetworkSink.onWritable); var signal = &response_stream.sink.signal; signal.* = JSC.WebCore.NetworkSink.JSSink.SinkSignal.init(.zero); @@ -322,96 +329,105 @@ pub fn writableStream( return response_stream.sink.toJS(globalThis); } -const S3UploadStreamWrapper = struct { +pub const S3UploadStreamWrapper = struct { const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{}); pub const ref = RefCount.ref; pub const deref = RefCount.deref; + pub const ResumableSink = @import("../bun.js/webcore/ResumableSink.zig").ResumableS3UploadSink; + const log = bun.Output.scoped(.S3UploadStream, false); ref_count: RefCount, - readable_stream_ref: JSC.WebCore.ReadableStream.Strong, - sink: *JSC.WebCore.NetworkSink, + + sink: ?*ResumableSink, task: *MultiPartUpload, + endPromise: JSC.JSPromise.Strong, callback: ?*const fn (S3UploadResult, *anyopaque) void, callback_context: *anyopaque, path: []const u8, // this is owned by the task not by the wrapper global: *JSC.JSGlobalObject, - pub fn resolve(result: S3UploadResult, self: *@This()) void { - const sink = self.sink; - defer self.deref(); - if (sink.endPromise.hasValue()) { - switch (result) { - .success => sink.endPromise.resolve(self.global, JSC.jsNumber(0)), - .failure => |err| { - if (!sink.done) { - sink.abort(); - return; - } - sink.endPromise.reject(self.global, err.toJS(self.global, self.path)); - }, - } + fn detachSink(self: *@This()) void { + log("detachSink {}", .{self.sink != null}); + if (self.sink) |sink| { + self.sink = null; + sink.deref(); } + } + pub fn onWritable(task: *MultiPartUpload, self: *@This(), _: u64) void { + log("onWritable {} {}", .{ self.sink != null, task.ended }); + // end was called we dont need to drain anymore + if (task.ended) return; + // we have more space in the queue, drain it + if (self.sink) |sink| { + sink.drain(); + } + } + + pub fn writeRequestData(this: *@This(), data: []const u8) bool { + log("writeRequestData {}", .{data.len}); + return this.task.writeBytes(data, false) catch bun.outOfMemory(); + } + + pub fn writeEndRequest(this: *@This(), err: ?JSC.JSValue) void { + log("writeEndRequest {}", .{err != null}); + this.detachSink(); + defer this.deref(); + if (err) |js_err| { + if (this.endPromise.hasValue() and !js_err.isEmptyOrUndefinedOrNull()) { + // if we have a explicit error, reject the promise + // if not when calling .fail will create a S3Error instance + // this match the previous behavior + this.endPromise.reject(this.global, js_err); + this.endPromise = .empty; + } + if (!this.task.ended) { + this.task.fail(.{ + .code = "UnknownError", + .message = "ReadableStream ended with an error", + }); + } + } else { + _ = this.task.writeBytes("", true) catch bun.outOfMemory(); + } + } + + pub fn resolve(result: S3UploadResult, self: *@This()) void { + log("resolve {any}", .{result}); + defer self.deref(); + switch (result) { + .success => { + if (self.endPromise.hasValue()) { + self.endPromise.resolve(self.global, JSC.jsNumber(0)); + self.endPromise = .empty; + } + }, + .failure => |err| { + if (self.sink) |sink| { + self.sink = null; + // sink in progress, cancel it (will call writeEndRequest for cleanup and will reject the endPromise) + sink.cancel(err.toJS(self.global, self.path)); + sink.deref(); + } else if (self.endPromise.hasValue()) { + self.endPromise.reject(self.global, err.toJS(self.global, self.path)); + self.endPromise = .empty; + } + }, + } + if (self.callback) |callback| { callback(result, self.callback_context); } } fn deinit(self: *@This()) void { - self.readable_stream_ref.deinit(); - self.sink.finalize(); - self.sink.deinit(); + log("deinit {}", .{self.sink != null}); + self.detachSink(); self.task.deref(); + self.endPromise.deinit(); bun.destroy(self); } }; -pub fn onUploadStreamResolveRequestStream(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { - var args = callframe.arguments_old(2); - var this = args.ptr[args.len - 1].asPromisePtr(S3UploadStreamWrapper); - defer this.deref(); - - if (this.readable_stream_ref.get(globalThis)) |stream| { - stream.done(globalThis); - } - this.readable_stream_ref.deinit(); - this.task.continueStream(); - - return .js_undefined; -} - -pub fn onUploadStreamRejectRequestStream(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { - const args = callframe.arguments_old(2); - var this = args.ptr[args.len - 1].asPromisePtr(S3UploadStreamWrapper); - defer this.deref(); - - const err = args.ptr[0]; - if (this.sink.endPromise.hasValue()) { - this.sink.endPromise.reject(globalThis, err); - } - - if (this.readable_stream_ref.get(globalThis)) |stream| { - stream.cancel(globalThis); - this.readable_stream_ref.deinit(); - } - if (this.sink.task) |task| { - if (task == .s3_upload) { - task.s3_upload.fail(.{ - .code = "UnknownError", - .message = "ReadableStream ended with an error", - }); - } - } - this.task.continueStream(); - - return .js_undefined; -} -comptime { - const jsonResolveRequestStream = JSC.toJSHostFn(onUploadStreamResolveRequestStream); - @export(&jsonResolveRequestStream, .{ .name = "Bun__S3UploadStream__onResolveRequestStream" }); - const jsonRejectRequestStream = JSC.toJSHostFn(onUploadStreamRejectRequestStream); - @export(&jsonRejectRequestStream, .{ .name = "Bun__S3UploadStream__onRejectRequestStream" }); -} - /// consumes the readable stream and upload to s3 pub fn uploadStream( this: *S3Credentials, @@ -428,14 +444,13 @@ pub fn uploadStream( ) JSC.JSValue { this.ref(); // ref the credentials const proxy_url = (proxy orelse ""); - if (readable_stream.isDisturbed(globalThis)) { - return JSC.JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, bun.String.static("ReadableStream is already disturbed").toErrorInstance(globalThis)); + return JSC.JSPromise.rejectedPromise(globalThis, bun.String.static("ReadableStream is already disturbed").toErrorInstance(globalThis)).toJS(); } switch (readable_stream.ptr) { .Invalid => { - return JSC.JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, bun.String.static("ReadableStream is invalid").toErrorInstance(globalThis)); + return JSC.JSPromise.rejectedPromise(globalThis, bun.String.static("ReadableStream is invalid").toErrorInstance(globalThis)).toJS(); }, inline .File, .Bytes => |stream| { if (stream.pending.result == .err) { @@ -454,7 +469,7 @@ pub fn uploadStream( } const task = bun.new(MultiPartUpload, .{ - .ref_count = .init(), + .ref_count = .initExactRefs(2), // +1 for the stream ctx (only deinit after task and context ended) .credentials = this, .path = bun.default_allocator.dupe(u8, path) catch bun.outOfMemory(), .proxy = if (proxy_url.len > 0) bun.default_allocator.dupe(u8, proxy_url) catch bun.outOfMemory() else "", @@ -471,125 +486,22 @@ pub fn uploadStream( task.poll_ref.ref(task.vm); - task.ref(); // + 1 for the stream sink - - var response_stream = JSC.WebCore.NetworkSink.new(.{ - .task = .{ .s3_upload = task }, - .buffer = .{}, - .globalThis = globalThis, - .encoded = false, - .endPromise = JSC.JSPromise.Strong.init(globalThis), - }).toSink(); - task.ref(); // + 1 for the stream wrapper - - const endPromise = response_stream.sink.endPromise.value(); const ctx = bun.new(S3UploadStreamWrapper, .{ - .ref_count = .init(), - .readable_stream_ref = JSC.WebCore.ReadableStream.Strong.init(readable_stream, globalThis), - .sink = &response_stream.sink, + .ref_count = .initExactRefs(2), // +1 for the stream sink (only deinit after both sink and task ended) + .sink = null, .callback = callback, .callback_context = callback_context, .path = task.path, .task = task, + .endPromise = JSC.JSPromise.Strong.init(globalThis), .global = globalThis, }); + // +1 because the ctx refs the sink + ctx.sink = S3UploadStreamWrapper.ResumableSink.initExactRefs(globalThis, readable_stream, ctx, 2); task.callback_context = @ptrCast(ctx); - // keep the task alive until we are done configuring the signal - task.ref(); - defer task.deref(); - - var signal = &response_stream.sink.signal; - - signal.* = JSC.WebCore.NetworkSink.JSSink.SinkSignal.init(.zero); - - // explicitly set it to a dead pointer - // we use this memory address to disable signals being sent - signal.clear(); - bun.assert(signal.isDead()); - - // We are already corked! - const assignment_result: JSC.JSValue = JSC.WebCore.NetworkSink.JSSink.assignToStream( - globalThis, - readable_stream.value, - response_stream, - @as(**anyopaque, @ptrCast(&signal.ptr)), - ); - - assignment_result.ensureStillAlive(); - - // assert that it was updated - bun.assert(!signal.isDead()); - - if (assignment_result.toError()) |err| { - if (response_stream.sink.endPromise.hasValue()) { - response_stream.sink.endPromise.reject(globalThis, err); - } - - task.fail(.{ - .code = "UnknownError", - .message = "ReadableStream ended with an error", - }); - readable_stream.cancel(globalThis); - return endPromise; - } - - if (!assignment_result.isEmptyOrUndefinedOrNull()) { - assignment_result.ensureStillAlive(); - // it returns a Promise when it goes through ReadableStreamDefaultReader - if (assignment_result.asAnyPromise()) |promise| { - switch (promise.status(globalThis.vm())) { - .pending => { - // if we eended and its not canceled the promise is the endPromise - // because assignToStream can return the sink.end() promise - // we set the endPromise in the NetworkSink so we need to resolve it - if (response_stream.sink.ended and !response_stream.sink.cancel) { - task.continueStream(); - - readable_stream.done(globalThis); - return endPromise; - } - ctx.ref(); - - assignment_result.then( - globalThis, - task.callback_context, - onUploadStreamResolveRequestStream, - onUploadStreamRejectRequestStream, - ); - // we need to wait the promise to resolve because can be an error/cancel here - if (!task.ended) - task.continueStream(); - }, - .fulfilled => { - task.continueStream(); - - readable_stream.done(globalThis); - }, - .rejected => { - if (response_stream.sink.endPromise.hasValue()) { - response_stream.sink.endPromise.reject(globalThis, promise.result(globalThis.vm())); - } - - task.fail(.{ - .code = "UnknownError", - .message = "ReadableStream ended with an error", - }); - readable_stream.cancel(globalThis); - }, - } - } else { - if (response_stream.sink.endPromise.hasValue()) { - response_stream.sink.endPromise.reject(globalThis, assignment_result); - } - - task.fail(.{ - .code = "UnknownError", - .message = "ReadableStream ended with an error", - }); - readable_stream.cancel(globalThis); - } - } - return endPromise; + task.onWritable = @ptrCast(&S3UploadStreamWrapper.onWritable); + task.continueStream(); + return ctx.endPromise.value(); } /// download a file from s3 chunk by chunk aka streaming (used on readableStream) diff --git a/src/s3/credentials.zig b/src/s3/credentials.zig index 3e965c286e..c72c6a9289 100644 --- a/src/s3/credentials.zig +++ b/src/s3/credentials.zig @@ -182,6 +182,17 @@ pub const S3Credentials = struct { new_credentials.options.partSize = @intCast(pageSize); } } + if (try opts.getOptional(globalObject, "partSize", i64)) |partSize| { + if (partSize < MultiPartUploadOptions.MIN_SINGLE_UPLOAD_SIZE and partSize > MultiPartUploadOptions.MAX_SINGLE_UPLOAD_SIZE) { + return globalObject.throwRangeError(partSize, .{ + .min = @intCast(MultiPartUploadOptions.MIN_SINGLE_UPLOAD_SIZE), + .max = @intCast(MultiPartUploadOptions.MAX_SINGLE_UPLOAD_SIZE), + .field_name = "partSize", + }); + } else { + new_credentials.options.partSize = @intCast(partSize); + } + } if (try opts.getOptional(globalObject, "queueSize", i32)) |queueSize| { if (queueSize < 1) { diff --git a/src/s3/multipart.zig b/src/s3/multipart.zig index a90468e252..854f82c76d 100644 --- a/src/s3/multipart.zig +++ b/src/s3/multipart.zig @@ -121,8 +121,7 @@ pub const MultiPartUpload = struct { vm: *JSC.VirtualMachine, globalThis: *JSC.JSGlobalObject, - buffered: std.ArrayListUnmanaged(u8) = .{}, - offset: usize = 0, + buffered: bun.io.StreamBuffer = .{}, path: []const u8, proxy: []const u8, @@ -143,6 +142,7 @@ pub const MultiPartUpload = struct { } = .not_started, callback: *const fn (S3SimpleRequest.S3UploadResult, *anyopaque) void, + onWritable: ?*const fn (task: *MultiPartUpload, ctx: *anyopaque, flushed: u64) void = null, callback_context: *anyopaque, const Self = @This(); @@ -220,6 +220,7 @@ pub const MultiPartUpload = struct { }, .etag => |etag| { log("onPartResponse {} success", .{this.partNumber}); + const sent = this.data.len; this.freeAllocatedSlice(); // we will need to order this this.ctx.multipart_etags.append(bun.default_allocator, .{ @@ -231,7 +232,7 @@ pub const MultiPartUpload = struct { // mark as available this.ctx.available.set(this.index); // drain more - this.ctx.drainEnqueuedParts(); + this.ctx.drainEnqueuedParts(sent); }, } } @@ -309,7 +310,7 @@ pub const MultiPartUpload = struct { .path = this.path, .method = .PUT, .proxy_url = this.proxyUrl(), - .body = this.buffered.items, + .body = this.buffered.slice(), .content_type = this.content_type, .acl = this.acl, .storage_class = this.storage_class, @@ -323,6 +324,10 @@ pub const MultiPartUpload = struct { }, .success => { log("singleSendUploadResponse success", .{}); + + if (this.onWritable) |callback| { + callback(this, this.callback_context, this.buffered.size()); + } this.done(); }, } @@ -374,7 +379,7 @@ pub const MultiPartUpload = struct { } /// Drain the parts, this is responsible for starting the parts and processing the buffered data - fn drainEnqueuedParts(this: *@This()) void { + fn drainEnqueuedParts(this: *@This(), flushed: u64) void { if (this.state == .finished or this.state == .singlefile_started) { return; } @@ -390,13 +395,24 @@ pub const MultiPartUpload = struct { } } const partSize = this.partSizeInBytes(); - if (this.ended or this.buffered.items.len >= partSize) { + if (this.ended or this.buffered.size() >= partSize) { this.processMultiPart(partSize); } - if (this.ended and this.available.mask == std.bit_set.IntegerBitSet(MAX_QUEUE_SIZE).initFull().mask) { - // we are done and no more parts are running - this.done(); + // empty queue + if (this.isQueueEmpty()) { + if (this.onWritable) |callback| { + callback(this, this.callback_context, flushed); + } + if (this.ended) { + // we are done and no more parts are running + this.done(); + } + } else if (!this.hasBackpressure() and flushed > 0) { + // we have more space in the queue, we can drain more + if (this.onWritable) |callback| { + callback(this, this.callback_context, flushed); + } } } /// Finalize the upload with a failure @@ -444,10 +460,10 @@ pub const MultiPartUpload = struct { this.multipart_upload_list.append(bun.default_allocator, "") catch bun.outOfMemory(); // will deref and ends after commit this.commitMultiPartRequest(); - } else { + } else if (this.state == .singlefile_started) { + this.state = .finished; // single file upload no need to commit this.callback(.{ .success = {} }, this.callback_context); - this.state = .finished; this.deref(); } } @@ -482,7 +498,7 @@ pub const MultiPartUpload = struct { log("startMultiPartRequestResult {s} success id: {s}", .{ this.path, this.upload_id }); this.state = .multipart_completed; // start draining the parts - this.drainEnqueuedParts(); + this.drainEnqueuedParts(0); }, // this is "unreachable" but we cover in case AWS returns 404 .not_found => this.fail(.{ @@ -504,12 +520,13 @@ pub const MultiPartUpload = struct { this.commitMultiPartRequest(); return; } + this.state = .finished; this.callback(.{ .failure = err }, this.callback_context); this.deref(); }, .success => { - this.callback(.{ .success = {} }, this.callback_context); this.state = .finished; + this.callback(.{ .success = {} }, this.callback_context); this.deref(); }, } @@ -588,25 +605,28 @@ pub const MultiPartUpload = struct { fn processMultiPart(this: *@This(), part_size: usize) void { log("processMultiPart {s} {d}", .{ this.path, part_size }); + if (this.buffered.isEmpty() and this.isQueueEmpty() and this.ended) { + // no more data to send and we are done + this.done(); + return; + } // need to split in multiple parts because of the size - var buffer = this.buffered.items[this.offset..]; - defer if (this.offset >= this.buffered.items.len) { - this.buffered.clearRetainingCapacity(); - this.offset = 0; + defer if (this.buffered.isEmpty()) { + this.buffered.reset(); }; - while (buffer.len > 0) { - const len = @min(part_size, buffer.len); + while (this.buffered.isNotEmpty()) { + const len = @min(part_size, this.buffered.size()); if (len < part_size and !this.ended) { log("processMultiPart {s} {d} slice too small", .{ this.path, len }); //slice is too small, we need to wait for more data break; } // if is one big chunk we can pass ownership and avoid dupe - if (len == this.buffered.items.len) { + if (this.buffered.cursor == 0 and this.buffered.size() == len) { // we need to know the allocated size to free the memory later - const allocated_size = this.buffered.capacity; - const slice = this.buffered.items; + const allocated_size = this.buffered.memoryCost(); + const slice = this.buffered.slice(); // we dont care about the result because we are sending everything if (this.enqueuePart(slice, allocated_size, false)) { @@ -615,7 +635,6 @@ pub const MultiPartUpload = struct { // queue is not full, we can clear the buffer part now owns the data // if its full we will retry later this.buffered = .{}; - this.offset = 0; return; } log("processMultiPart {s} {d} queue full", .{ this.path, slice.len }); @@ -623,13 +642,12 @@ pub const MultiPartUpload = struct { return; } - const slice = buffer[0..len]; - buffer = buffer[len..]; + const slice = this.buffered.slice()[0..len]; // allocated size is the slice len because we dupe the buffer if (this.enqueuePart(slice, slice.len, true)) { log("processMultiPart {s} {d} slice enqueued", .{ this.path, slice.len }); // queue is not full, we can set the offset - this.offset += len; + this.buffered.wrote(len); } else { log("processMultiPart {s} {d} queue full", .{ this.path, slice.len }); // queue is full stop enqueue and retry later @@ -642,7 +660,7 @@ pub const MultiPartUpload = struct { return this.proxy; } fn processBuffered(this: *@This(), part_size: usize) void { - if (this.ended and this.buffered.items.len < this.partSizeInBytes() and this.state == .not_started) { + if (this.ended and this.buffered.size() < this.partSizeInBytes() and this.state == .not_started) { log("processBuffered {s} singlefile_started", .{this.path}); this.state = .singlefile_started; // we can do only 1 request @@ -650,7 +668,7 @@ pub const MultiPartUpload = struct { .path = this.path, .method = .PUT, .proxy_url = this.proxyUrl(), - .body = this.buffered.items, + .body = this.buffered.slice(), .content_type = this.content_type, .acl = this.acl, .storage_class = this.storage_class, @@ -674,34 +692,74 @@ pub const MultiPartUpload = struct { } } - pub fn sendRequestData(this: *@This(), chunk: []const u8, is_last: bool) void { - if (this.ended) return; + pub fn hasBackpressure(this: *@This()) bool { + // if we dont have any space in the queue, we have backpressure + // since we are not allowed to send more data + const index = this.available.findFirstSet() orelse return true; + return index >= this.options.queueSize; + } + + pub fn isQueueEmpty(this: *@This()) bool { + return this.available.mask == std.bit_set.IntegerBitSet(MAX_QUEUE_SIZE).initFull().mask; + } + + pub const WriteEncoding = enum { + bytes, + latin1, + utf16, + }; + + fn write(this: *@This(), chunk: []const u8, is_last: bool, comptime encoding: WriteEncoding) bun.OOM!bool { + if (this.ended) return true; // no backpressure since we are done + // we may call done inside processBuffered so we ensure that we keep a ref until we are done + this.ref(); + defer this.deref(); if (this.state == .wait_stream_check and chunk.len == 0 and is_last) { // we do this because stream will close if the file dont exists and we dont wanna to send an empty part in this case this.ended = true; - if (this.buffered.items.len > 0) { + if (this.buffered.size() > 0) { this.processBuffered(this.partSizeInBytes()); } - return; + return !this.hasBackpressure(); } if (is_last) { this.ended = true; if (chunk.len > 0) { - this.buffered.appendSlice(bun.default_allocator, chunk) catch bun.outOfMemory(); + switch (encoding) { + .bytes => try this.buffered.write(chunk), + .latin1 => try this.buffered.writeLatin1(chunk, true), + .utf16 => try this.buffered.writeUTF16(@alignCast(std.mem.bytesAsSlice(u16, chunk))), + } } this.processBuffered(this.partSizeInBytes()); } else { // still have more data and receive empty, nothing todo here - if (chunk.len == 0) return; - this.buffered.appendSlice(bun.default_allocator, chunk) catch bun.outOfMemory(); + if (chunk.len == 0) return this.hasBackpressure(); + switch (encoding) { + .bytes => try this.buffered.write(chunk), + .latin1 => try this.buffered.writeLatin1(chunk, true), + .utf16 => try this.buffered.writeUTF16(@alignCast(std.mem.bytesAsSlice(u16, chunk))), + } const partSize = this.partSizeInBytes(); - if (this.buffered.items.len >= partSize) { + if (this.buffered.size() >= partSize) { // send the part we have enough data this.processBuffered(partSize); - return; } // wait for more } + return !this.hasBackpressure(); + } + + pub fn writeLatin1(this: *@This(), chunk: []const u8, is_last: bool) bun.OOM!bool { + return try this.write(chunk, is_last, .latin1); + } + + pub fn writeUTF16(this: *@This(), chunk: []const u8, is_last: bool) bun.OOM!bool { + return try this.write(chunk, is_last, .utf16); + } + + pub fn writeBytes(this: *@This(), chunk: []const u8, is_last: bool) bun.OOM!bool { + return try this.write(chunk, is_last, .bytes); } }; diff --git a/src/sql/postgres.zig b/src/sql/postgres.zig index 53159e2d69..25ab8bfd1e 100644 --- a/src/sql/postgres.zig +++ b/src/sql/postgres.zig @@ -1543,7 +1543,7 @@ pub const PostgresSQLConnection = struct { pub fn flushData(this: *PostgresSQLConnection) void { const chunk = this.write_buffer.remaining(); if (chunk.len == 0) return; - const wrote = this.socket.write(chunk, false); + const wrote = this.socket.write(chunk); if (wrote > 0) { SocketMonitor.write(chunk[0..@intCast(wrote)]); this.write_buffer.consume(@intCast(wrote)); @@ -1623,7 +1623,7 @@ pub const PostgresSQLConnection = struct { 0x04, 0xD2, 0x16, 0x2F, // SSL request code }; - const written = socket.write(ssl_request[offset..], false); + const written = socket.write(ssl_request[offset..]); if (written > 0) { this.tls_status = .{ .message_sent = offset + @as(u8, @intCast(written)), diff --git a/src/transpiler.zig b/src/transpiler.zig index fdede87e22..884468d32d 100644 --- a/src/transpiler.zig +++ b/src/transpiler.zig @@ -23,7 +23,7 @@ const Fs = @import("fs.zig"); const schema = @import("api/schema.zig"); const Api = schema.Api; const _resolver = @import("./resolver/resolver.zig"); -const MimeType = @import("./http/mime_type.zig"); +const MimeType = @import("./http/MimeType.zig"); const runtime = @import("./runtime.zig"); const MacroRemap = @import("./resolver/package_json.zig").MacroMap; const DebugLogs = _resolver.DebugLogs; diff --git a/src/valkey/valkey.zig b/src/valkey/valkey.zig index b84ffe8db3..af0947becc 100644 --- a/src/valkey/valkey.zig +++ b/src/valkey/valkey.zig @@ -346,7 +346,7 @@ pub const ValkeyClient = struct { pub fn flushData(this: *ValkeyClient) bool { const chunk = this.write_buffer.remaining(); if (chunk.len == 0) return false; - const wrote = this.socket.write(chunk, false); + const wrote = this.socket.write(chunk); if (wrote > 0) { this.write_buffer.consume(@intCast(wrote)); } @@ -795,7 +795,7 @@ pub const ValkeyClient = struct { // Optimization: avoid cloning the data an extra time. defer this.allocator.free(data); - const wrote = this.socket.write(data, false); + const wrote = this.socket.write(data); const unwritten = data[@intCast(@max(wrote, 0))..]; if (unwritten.len > 0) { diff --git a/test/internal/ban-words.test.ts b/test/internal/ban-words.test.ts index 62632138d9..8d078763e3 100644 --- a/test/internal/ban-words.test.ts +++ b/test/internal/ban-words.test.ts @@ -41,9 +41,8 @@ const words: Record "std.fs.File": { reason: "Prefer bun.sys + bun.FD instead of std.fs", limit: 62 }, ".stdFile()": { reason: "Prefer bun.sys + bun.FD instead of std.fs.File. Zig hides 'errno' when Bun wants to match libuv", limit: 18 }, ".stdDir()": { reason: "Prefer bun.sys + bun.FD instead of std.fs.File. Zig hides 'errno' when Bun wants to match libuv", limit: 49 }, - ".arguments_old(": { reason: "Please migrate to .argumentsAsArray() or another argument API", limit: 284 }, + ".arguments_old(": { reason: "Please migrate to .argumentsAsArray() or another argument API", limit: 280 }, "// autofix": { reason: "Evaluate if this variable should be deleted entirely or explicitly discarded.", limit: 174 }, - "global.hasException": { reason: "Incompatible with strict exception checks. Use a CatchScope instead.", limit: 28 }, "globalObject.hasException": { reason: "Incompatible with strict exception checks. Use a CatchScope instead.", limit: 47 }, "globalThis.hasException": { reason: "Incompatible with strict exception checks. Use a CatchScope instead.", limit: 140 }, diff --git a/test/js/bun/s3/s3-storage-class.test.ts b/test/js/bun/s3/s3-storage-class.test.ts index 838e8873b1..285bc032c9 100644 --- a/test/js/bun/s3/s3-storage-class.test.ts +++ b/test/js/bun/s3/s3-storage-class.test.ts @@ -175,7 +175,7 @@ describe("s3 - Storage class", () => { const smallFile = Buffer.alloc(10 * 1024); for (let i = 0; i < 10; i++) { - await writer.write(smallFile); + writer.write(smallFile); } await writer.end(); @@ -249,7 +249,7 @@ describe("s3 - Storage class", () => { const bigFile = Buffer.alloc(10 * 1024 * 1024); for (let i = 0; i < 10; i++) { - await writer.write(bigFile); + writer.write(bigFile); } await writer.end(); diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts index 5582a37b32..df6688b805 100644 --- a/test/js/bun/s3/s3.test.ts +++ b/test/js/bun/s3/s3.test.ts @@ -565,6 +565,26 @@ for (let credentials of allCredentials) { await writer.end(); expect(await s3file.text()).toBe(mediumPayload.repeat(2)); }); + it("should be able to upload large files using flush and partSize", async () => { + const s3file = file(tmp_filename, options); + + const writer = s3file.writer({ + //@ts-ignore + partSize: mediumPayload.length, + }); + writer.write(mediumPayload); + writer.write(mediumPayload); + let total = 0; + while (true) { + const flushed = await writer.flush(); + if (flushed === 0) break; + expect(flushed).toBe(Buffer.byteLength(mediumPayload)); + total += flushed; + } + expect(total).toBe(Buffer.byteLength(mediumPayload) * 2); + await writer.end(); + expect(await s3file.text()).toBe(mediumPayload.repeat(2)); + }); it("should be able to upload large files in one go using Bun.write", async () => { { await Bun.write(file(tmp_filename, options), bigPayload); @@ -680,6 +700,26 @@ for (let credentials of allCredentials) { } }, 10_000); + it("should be able to upload large files using flush and partSize", async () => { + const s3file = s3(tmp_filename, options); + + const writer = s3file.writer({ + partSize: mediumPayload.length, + }); + writer.write(mediumPayload); + writer.write(mediumPayload); + let total = 0; + while (true) { + const flushed = await writer.flush(); + if (flushed === 0) break; + expect(flushed).toBe(Buffer.byteLength(mediumPayload)); + total += flushed; + } + expect(total).toBe(Buffer.byteLength(mediumPayload) * 2); + await writer.end(); + expect(await s3file.text()).toBe(mediumPayload.repeat(2)); + }); + it("should be able to upload large files in one go using S3File.write", async () => { { const s3File = s3(tmp_filename, options);