feat(websocket): add HTTP/HTTPS proxy support (#25614)

## Summary

Add `proxy` option to WebSocket constructor for connecting through HTTP
CONNECT proxies.

### Features
- Support for `ws://` and `wss://` through HTTP proxies
- Support for `ws://` and `wss://` through HTTPS proxies (with
`rejectUnauthorized: false`)
- Proxy authentication via URL credentials (Basic auth)
- Custom proxy headers support
- Full TLS options (`ca`, `cert`, `key`, etc.) for target connections
using `SSLConfig.fromJS`

### API

```javascript
// String format
new WebSocket("wss://example.com", { proxy: "http://proxy:8080" })

// With credentials
new WebSocket("wss://example.com", { proxy: "http://user:pass@proxy:8080" })

// Object format with custom headers
new WebSocket("wss://example.com", {
  proxy: { url: "http://proxy:8080", headers: { "X-Custom": "value" } }
})

// HTTPS proxy
new WebSocket("ws://example.com", {
  proxy: "https://proxy:8443",
  tls: { rejectUnauthorized: false }
})
```

### Implementation

| File | Changes |
|------|---------|
| `WebSocketUpgradeClient.zig` | Proxy state machine and CONNECT
handling |
| `WebSocketProxyTunnel.zig` | **New** - TLS tunnel inside CONNECT for
wss:// through HTTP proxy |
| `JSWebSocket.cpp` | Parse proxy option and TLS options using
`SSLConfig.fromJS` |
| `WebSocket.cpp` | Pass proxy parameters to Zig, handle HTTPS proxy
socket selection |
| `bun.d.ts` | Add `proxy` and full TLS options to WebSocket types |

### Supported Scenarios

| Scenario | Status |
|----------|--------|
| ws:// through HTTP proxy |  Working |
| wss:// through HTTP proxy |  Working (TLS tunnel) |
| ws:// through HTTPS proxy |  Working (with `rejectUnauthorized:
false`) |
| wss:// through HTTPS proxy |  Working (with `rejectUnauthorized:
false`) |
| Proxy authentication (Basic) |  Working |
| Custom proxy headers |  Working |
| Custom CA for HTTPS proxy |   Working |

## Test plan

- [x] API tests verify proxy option is accepted in various formats
- [x] Functional tests with local HTTP CONNECT proxy server
- [x] Proxy authentication tests (Basic auth)
- [x] HTTPS proxy tests with `rejectUnauthorized: false`
- [x] Error handling tests (auth failures, wrong credentials)

Run tests: `bun test test/js/web/websocket/websocket-proxy.test.ts`

## Changelog

- Added `proxy` option to `WebSocket` constructor for HTTP/HTTPS proxy
support
- Added full TLS options (`ca`, `cert`, `key`, `passphrase`, etc.) to
`WebSocket` constructor

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Ciro Spaciari
2026-01-08 16:21:34 -08:00
committed by GitHub
parent 24b97994e3
commit c90c0e69cb
29 changed files with 3635 additions and 170 deletions

View File

@@ -3290,16 +3290,29 @@ declare module "bun" {
type WebSocketOptionsTLS = {
/**
* Options for the TLS connection
* Options for the TLS connection.
*
* Supports full TLS configuration including custom CA certificates,
* client certificates, and other TLS settings (same as fetch).
*
* @example
* ```ts
* // Using BunFile for certificates
* const ws = new WebSocket("wss://example.com", {
* tls: {
* ca: Bun.file("./ca.pem")
* }
* });
*
* // Using Buffer
* const ws = new WebSocket("wss://example.com", {
* tls: {
* ca: fs.readFileSync("./ca.pem")
* }
* });
* ```
*/
tls?: {
/**
* Whether to reject the connection if the certificate is not valid
*
* @default true
*/
rejectUnauthorized?: boolean;
};
tls?: TLSOptions;
};
type WebSocketOptionsHeaders = {
@@ -3309,10 +3322,57 @@ declare module "bun" {
headers?: import("node:http").OutgoingHttpHeaders;
};
type WebSocketOptionsProxy = {
/**
* HTTP proxy to use for the WebSocket connection.
*
* Can be a string URL or an object with `url` and optional `headers`.
*
* @example
* ```ts
* // String format
* const ws = new WebSocket("wss://example.com", {
* proxy: "http://proxy.example.com:8080"
* });
*
* // With credentials
* const ws = new WebSocket("wss://example.com", {
* proxy: "http://user:pass@proxy.example.com:8080"
* });
*
* // Object format with custom headers
* const ws = new WebSocket("wss://example.com", {
* proxy: {
* url: "http://proxy.example.com:8080",
* headers: {
* "Proxy-Authorization": "Bearer token"
* }
* }
* });
* ```
*/
proxy?:
| string
| {
/**
* The proxy URL (http:// or https://)
*/
url: string;
/**
* Custom headers to send to the proxy server.
* Supports plain objects or Headers class instances.
*/
headers?: import("node:http").OutgoingHttpHeaders | Headers;
};
};
/**
* Constructor options for the `Bun.WebSocket` client
*/
type WebSocketOptions = WebSocketOptionsProtocolsOrProtocol & WebSocketOptionsTLS & WebSocketOptionsHeaders;
type WebSocketOptions = WebSocketOptionsProtocolsOrProtocol &
WebSocketOptionsTLS &
WebSocketOptionsHeaders &
WebSocketOptionsProxy;
interface WebSocketEventMap {
close: CloseEvent;

View File

@@ -445,6 +445,9 @@ pub fn SSLWrapper(comptime T: type) type {
log("triggering data callback (read {d}) and resetting read buffer", .{read});
// we filled the buffer
this.triggerDataCallback(buffer[0..read]);
// The callback may have closed the connection - check before continuing
// Check ssl first as a proxy for whether we were deinited
if (this.ssl == null or this.flags.closed_notified) return false;
read = 0;
}
}
@@ -452,6 +455,9 @@ pub fn SSLWrapper(comptime T: type) type {
if (read > 0) {
log("triggering data callback (read {d})", .{read});
this.triggerDataCallback(buffer[0..read]);
// The callback may have closed the connection
// Check ssl first as a proxy for whether we were deinited
if (this.ssl == null or this.flags.closed_notified) return false;
}
return true;
}

View File

@@ -91,6 +91,26 @@ pub fn asUSockets(this: *const SSLConfig) uws.SocketContext.BunSocketContextOpti
return ctx_opts;
}
/// Returns socket options for client-side TLS with manual verification.
/// Sets request_cert=1 (to receive server cert) and reject_unauthorized=0
/// (to handle verification manually in handshake callback).
pub fn asUSocketsForClientVerification(this: *const SSLConfig) uws.SocketContext.BunSocketContextOptions {
var opts = this.asUSockets();
opts.request_cert = 1;
opts.reject_unauthorized = 0;
return opts;
}
/// Returns a copy of this config for client-side TLS with manual verification.
/// Sets request_cert=1 (to receive server cert) and reject_unauthorized=0
/// (to handle verification manually in handshake callback).
pub fn forClientVerification(this: SSLConfig) SSLConfig {
var copy = this;
copy.request_cert = 1;
copy.reject_unauthorized = 0;
return copy;
}
pub fn isSame(this: *const SSLConfig, other: *const SSLConfig) bool {
inline for (comptime std.meta.fields(SSLConfig)) |field| {
const first = @field(this, field.name);

View File

@@ -92,4 +92,19 @@ JSC::JSValue getIfPropertyExistsPrototypePollutionMitigation(JSC::VM& vm, JSC::J
return value;
}
JSC::JSValue getOwnPropertyIfExists(JSC::JSGlobalObject* globalObject, JSC::JSObject* object, const JSC::PropertyName& name)
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
PropertySlot slot(object, PropertySlot::InternalMethodType::GetOwnProperty, nullptr);
if (!object->methodTable()->getOwnPropertySlot(object, globalObject, name, slot)) {
RETURN_IF_EXCEPTION(scope, {});
return JSC::jsUndefined();
}
RETURN_IF_EXCEPTION(scope, {});
JSValue value = slot.getValue(globalObject, name);
RETURN_IF_EXCEPTION(scope, {});
return value;
}
}

View File

@@ -24,4 +24,11 @@ ALWAYS_INLINE JSC::JSValue getIfPropertyExistsPrototypePollutionMitigation(JSC::
return getIfPropertyExistsPrototypePollutionMitigation(JSC::getVM(globalObject), globalObject, object, name);
}
/**
* Gets an own property only (no prototype chain lookup).
* Returns jsUndefined() if property doesn't exist as own property.
* This is the strictest form of property access - use for security-critical options.
*/
JSC::JSValue getOwnPropertyIfExists(JSC::JSGlobalObject* globalObject, JSC::JSObject* object, const JSC::PropertyName& name);
}

View File

@@ -575,7 +575,14 @@ BUN_DECLARE_HOST_FUNCTION(NetworkSink__write);
#ifdef __cplusplus
ZIG_DECL void Bun__WebSocketHTTPClient__cancel(WebSocketHTTPClient* arg0);
ZIG_DECL WebSocketHTTPClient* Bun__WebSocketHTTPClient__connect(JSC::JSGlobalObject* arg0, void* arg1, CppWebSocket* arg2, const ZigString* arg3, uint16_t arg4, const ZigString* arg5, const ZigString* arg6, ZigString* arg7, ZigString* arg8, size_t arg9);
ZIG_DECL WebSocketHTTPClient* Bun__WebSocketHTTPClient__connect(
JSC::JSGlobalObject* globalObject, void* socketContext, CppWebSocket* websocket,
const ZigString* host, uint16_t port, const ZigString* path, const ZigString* protocols,
ZigString* headerNames, ZigString* headerValues, size_t headerCount,
const ZigString* proxyHost, uint16_t proxyPort,
const ZigString* proxyAuthorization,
ZigString* proxyHeaderNames, ZigString* proxyHeaderValues, size_t proxyHeaderCount,
void* sslConfig, bool targetIsSecure);
ZIG_DECL void Bun__WebSocketHTTPClient__register(JSC::JSGlobalObject* arg0, void* arg1, void* arg2);
ZIG_DECL size_t Bun__WebSocketHTTPClient__memoryCost(WebSocketHTTPClient* arg0);
#endif
@@ -583,9 +590,19 @@ ZIG_DECL size_t Bun__WebSocketHTTPClient__memoryCost(WebSocketHTTPClient* arg0);
#ifdef __cplusplus
ZIG_DECL void Bun__WebSocketHTTPSClient__cancel(WebSocketHTTPSClient* arg0);
ZIG_DECL WebSocketHTTPSClient* Bun__WebSocketHTTPSClient__connect(JSC::JSGlobalObject* arg0, void* arg1, CppWebSocket* arg2, const ZigString* arg3, uint16_t arg4, const ZigString* arg5, const ZigString* arg6, ZigString* arg7, ZigString* arg8, size_t arg9);
ZIG_DECL WebSocketHTTPSClient* Bun__WebSocketHTTPSClient__connect(
JSC::JSGlobalObject* globalObject, void* socketContext, CppWebSocket* websocket,
const ZigString* host, uint16_t port, const ZigString* path, const ZigString* protocols,
ZigString* headerNames, ZigString* headerValues, size_t headerCount,
const ZigString* proxyHost, uint16_t proxyPort,
const ZigString* proxyAuthorization,
ZigString* proxyHeaderNames, ZigString* proxyHeaderValues, size_t proxyHeaderCount,
void* sslConfig, bool targetIsSecure);
ZIG_DECL void Bun__WebSocketHTTPSClient__register(JSC::JSGlobalObject* arg0, void* arg1, void* arg2);
ZIG_DECL size_t Bun__WebSocketHTTPSClient__memoryCost(WebSocketHTTPSClient* arg0);
// Parse TLS options from JavaScript object using SSLConfig.fromJS
ZIG_DECL void* Bun__WebSocket__parseSSLConfig(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue tlsValue);
#endif
#ifdef __cplusplus
@@ -593,7 +610,7 @@ ZIG_DECL size_t Bun__WebSocketHTTPSClient__memoryCost(WebSocketHTTPSClient* arg0
ZIG_DECL void Bun__WebSocketClient__cancel(WebSocketClient* arg0);
ZIG_DECL void Bun__WebSocketClient__close(WebSocketClient* arg0, uint16_t arg1, const ZigString* arg2);
ZIG_DECL void Bun__WebSocketClient__finalize(WebSocketClient* arg0);
ZIG_DECL void* Bun__WebSocketClient__init(CppWebSocket* arg0, void* arg1, void* arg2, JSC::JSGlobalObject* arg3, unsigned char* arg4, size_t arg5, const PerMessageDeflateParams* arg6);
ZIG_DECL void* Bun__WebSocketClient__init(CppWebSocket* arg0, void* arg1, void* arg2, JSC::JSGlobalObject* arg3, unsigned char* arg4, size_t arg5, const PerMessageDeflateParams* arg6, void* customSSLCtx);
ZIG_DECL void Bun__WebSocketClient__register(JSC::JSGlobalObject* arg0, void* arg1, void* arg2);
ZIG_DECL void Bun__WebSocketClient__writeBinaryData(WebSocketClient* arg0, const unsigned char* arg1, size_t arg2, unsigned char arg3);
ZIG_DECL void Bun__WebSocketClient__writeString(WebSocketClient* arg0, const ZigString* arg1, unsigned char arg2);
@@ -606,7 +623,7 @@ ZIG_DECL size_t Bun__WebSocketClient__memoryCost(WebSocketClient* arg0);
ZIG_DECL void Bun__WebSocketClientTLS__cancel(WebSocketClientTLS* arg0);
ZIG_DECL void Bun__WebSocketClientTLS__close(WebSocketClientTLS* arg0, uint16_t arg1, const ZigString* arg2);
ZIG_DECL void Bun__WebSocketClientTLS__finalize(WebSocketClientTLS* arg0);
ZIG_DECL void* Bun__WebSocketClientTLS__init(CppWebSocket* arg0, void* arg1, void* arg2, JSC::JSGlobalObject* arg3, unsigned char* arg4, size_t arg5, const PerMessageDeflateParams* arg6);
ZIG_DECL void* Bun__WebSocketClientTLS__init(CppWebSocket* arg0, void* arg1, void* arg2, JSC::JSGlobalObject* arg3, unsigned char* arg4, size_t arg5, const PerMessageDeflateParams* arg6, void* customSSLCtx);
ZIG_DECL void Bun__WebSocketClientTLS__register(JSC::JSGlobalObject* arg0, void* arg1, void* arg2);
ZIG_DECL void Bun__WebSocketClientTLS__writeBinaryData(WebSocketClientTLS* arg0, const unsigned char* arg1, size_t arg2, unsigned char arg3);
ZIG_DECL void Bun__WebSocketClientTLS__writeString(WebSocketClientTLS* arg0, const ZigString* arg1, unsigned char arg2);

View File

@@ -63,6 +63,9 @@
#include <wtf/URL.h>
#include "IDLTypes.h"
#include "FetchHeaders.h"
#include "JSFetchHeaders.h"
#include "headers.h"
#include "ObjectBindings.h"
namespace WebCore {
using namespace JSC;
@@ -210,10 +213,16 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG
Vector<String> protocols;
int rejectUnauthorized = -1;
void* sslConfig = nullptr; // SSLConfig pointer from Zig
auto headersInit = std::optional<Converter<IDLUnion<IDLSequence<IDLSequence<IDLByteString>>, IDLRecord<IDLByteString, IDLByteString>>>::ReturnType>();
// Proxy options
String proxyUrl;
auto proxyHeadersInit = std::optional<Converter<IDLUnion<IDLSequence<IDLSequence<IDLByteString>>, IDLRecord<IDLByteString, IDLByteString>>>::ReturnType>();
if (JSC::JSObject* options = optionsObjectValue.getObject()) {
const auto& builtinnames = WebCore::builtinNames(vm);
auto headersValue = options->getIfPropertyExists(globalObject, builtinnames.headersPublicName());
auto headersValue = Bun::getOwnPropertyIfExists(globalObject, options, builtinnames.headersPublicName());
RETURN_IF_EXCEPTION(throwScope, {});
if (headersValue) {
if (!headersValue.isUndefinedOrNull()) {
@@ -222,7 +231,7 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG
}
}
auto protocolsValue = options->getIfPropertyExists(globalObject, PropertyName(Identifier::fromString(vm, "protocols"_s)));
auto protocolsValue = Bun::getOwnPropertyIfExists(globalObject, options, PropertyName(Identifier::fromString(vm, "protocols"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (protocolsValue) {
if (!protocolsValue.isUndefinedOrNull()) {
@@ -230,7 +239,7 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG
RETURN_IF_EXCEPTION(throwScope, {});
}
} else {
auto protocolValue = options->getIfPropertyExists(globalObject, PropertyName(Identifier::fromString(vm, "protocol"_s)));
auto protocolValue = Bun::getOwnPropertyIfExists(globalObject, options, PropertyName(Identifier::fromString(vm, "protocol"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (protocolValue) {
if (!protocolValue.isUndefinedOrNull()) {
@@ -240,17 +249,179 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG
}
}
auto tlsOptionsValue = options->getIfPropertyExists(globalObject, PropertyName(Identifier::fromString(vm, "tls"_s)));
// Parse TLS options using Zig's SSLConfig.fromJS for full TLS option support
JSValue tlsOptionsValue = Bun::getOwnPropertyIfExists(globalObject, options, PropertyName(Identifier::fromString(vm, "tls"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (tlsOptionsValue) {
if (!tlsOptionsValue.isUndefinedOrNull() && tlsOptionsValue.isObject()) {
if (JSC::JSObject* tlsOptions = tlsOptionsValue.getObject()) {
if (tlsOptionsValue && !tlsOptionsValue.isUndefinedOrNull() && tlsOptionsValue.isObject()) {
// Also extract rejectUnauthorized for backwards compatibility
if (JSC::JSObject* tlsOptions = tlsOptionsValue.getObject()) {
auto rejectUnauthorizedValue = Bun::getOwnPropertyIfExists(globalObject, tlsOptions, PropertyName(Identifier::fromString(vm, "rejectUnauthorized"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (rejectUnauthorizedValue && !rejectUnauthorizedValue.isUndefinedOrNull() && rejectUnauthorizedValue.isBoolean()) {
rejectUnauthorized = rejectUnauthorizedValue.asBoolean() ? 1 : 0;
}
}
auto rejectUnauthorizedValue = tlsOptions->getIfPropertyExists(globalObject, PropertyName(Identifier::fromString(vm, "rejectUnauthorized"_s)));
// Parse full TLS options using Zig's SSLConfig.fromJS
sslConfig = Bun__WebSocket__parseSSLConfig(globalObject, JSValue::encode(tlsOptionsValue));
RETURN_IF_EXCEPTION(throwScope, {});
}
// Parse proxy option - can be string or { url, headers }
auto proxyValue = Bun::getOwnPropertyIfExists(globalObject, options, PropertyName(Identifier::fromString(vm, "proxy"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (proxyValue) {
if (!proxyValue.isUndefinedOrNull()) {
if (proxyValue.isString()) {
// proxy: "http://proxy:8080"
proxyUrl = convert<IDLUSVString>(*lexicalGlobalObject, proxyValue);
RETURN_IF_EXCEPTION(throwScope, {});
if (rejectUnauthorizedValue) {
if (!rejectUnauthorizedValue.isUndefinedOrNull() && rejectUnauthorizedValue.isBoolean()) {
rejectUnauthorized = rejectUnauthorizedValue.asBoolean() ? 1 : 0;
} else if (proxyValue.isObject()) {
// proxy: { url: "http://proxy:8080", headers: {...} }
if (JSC::JSObject* proxyOptions = proxyValue.getObject()) {
auto proxyUrlValue = Bun::getOwnPropertyIfExists(globalObject, proxyOptions, PropertyName(Identifier::fromString(vm, "url"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (proxyUrlValue && !proxyUrlValue.isUndefinedOrNull()) {
proxyUrl = convert<IDLUSVString>(*lexicalGlobalObject, proxyUrlValue);
RETURN_IF_EXCEPTION(throwScope, {});
}
auto proxyHeadersValue = Bun::getOwnPropertyIfExists(globalObject, proxyOptions, builtinnames.headersPublicName());
RETURN_IF_EXCEPTION(throwScope, {});
if (proxyHeadersValue && !proxyHeadersValue.isUndefinedOrNull()) {
// Check if it's already a Headers instance (like fetch does)
if (auto* jsHeaders = jsDynamicCast<JSFetchHeaders*>(proxyHeadersValue)) {
// Convert FetchHeaders to the Init variant
auto& headers = jsHeaders->wrapped();
Vector<KeyValuePair<String, String>> pairs;
auto iterator = headers.createIterator(false);
while (auto value = iterator.next()) {
pairs.append({ value->key, value->value });
}
proxyHeadersInit = WTF::move(pairs);
} else {
// Fall back to IDL conversion for plain objects/arrays
proxyHeadersInit = convert<IDLUnion<IDLSequence<IDLSequence<IDLByteString>>, IDLRecord<IDLByteString, IDLByteString>>>(*lexicalGlobalObject, proxyHeadersValue);
RETURN_IF_EXCEPTION(throwScope, {});
}
}
}
}
}
}
// Parse agent option - extract proxy from agent.proxy if no explicit proxy
// This supports HttpsProxyAgent and similar agent libraries
if (proxyUrl.isNull() || proxyUrl.isEmpty()) {
auto agentValue = Bun::getOwnPropertyIfExists(globalObject, options, PropertyName(Identifier::fromString(vm, "agent"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (agentValue && !agentValue.isUndefinedOrNull() && agentValue.isObject()) {
if (JSC::JSObject* agentObj = agentValue.getObject()) {
// Get agent.proxy (can be URL object or string)
auto agentProxyValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "proxy"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (agentProxyValue && !agentProxyValue.isUndefinedOrNull()) {
if (agentProxyValue.isString()) {
proxyUrl = convert<IDLUSVString>(*lexicalGlobalObject, agentProxyValue);
} else if (agentProxyValue.isObject()) {
// URL object - get .href property
if (JSC::JSObject* urlObj = agentProxyValue.getObject()) {
auto hrefValue = Bun::getOwnPropertyIfExists(globalObject, urlObj, PropertyName(Identifier::fromString(vm, "href"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (hrefValue && hrefValue.isString()) {
proxyUrl = convert<IDLUSVString>(*lexicalGlobalObject, hrefValue);
}
}
}
RETURN_IF_EXCEPTION(throwScope, {});
}
// Get agent.proxyHeaders
auto proxyHeadersValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "proxyHeaders"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (proxyHeadersValue && !proxyHeadersValue.isUndefinedOrNull()) {
// If it's a function, call it
if (proxyHeadersValue.isCallable()) {
auto callData = JSC::getCallData(proxyHeadersValue);
proxyHeadersValue = JSC::call(lexicalGlobalObject, proxyHeadersValue, callData, agentObj, JSC::MarkedArgumentBuffer());
RETURN_IF_EXCEPTION(throwScope, {});
}
if (!proxyHeadersValue.isUndefinedOrNull()) {
// Check if it's already a Headers instance (like fetch does)
if (auto* jsHeaders = jsDynamicCast<JSFetchHeaders*>(proxyHeadersValue)) {
// Convert FetchHeaders to the Init variant
auto& headers = jsHeaders->wrapped();
Vector<KeyValuePair<String, String>> pairs;
auto iterator = headers.createIterator(false);
while (auto value = iterator.next()) {
pairs.append({ value->key, value->value });
}
proxyHeadersInit = WTF::move(pairs);
} else {
// Fall back to IDL conversion for plain objects/arrays
proxyHeadersInit = convert<IDLUnion<IDLSequence<IDLSequence<IDLByteString>>, IDLRecord<IDLByteString, IDLByteString>>>(*lexicalGlobalObject, proxyHeadersValue);
RETURN_IF_EXCEPTION(throwScope, {});
}
}
}
// Get TLS options from agent.connectOpts or agent.options
// We build a filtered object with only supported TLS options (ca, cert, key, passphrase, rejectUnauthorized)
// to avoid passing invalid properties like ALPNProtocols to the SSL parser
if (rejectUnauthorized == -1 && !sslConfig) {
auto connectOptsValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "connectOpts"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (!connectOptsValue || connectOptsValue.isUndefinedOrNull()) {
connectOptsValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "options"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
}
if (connectOptsValue && !connectOptsValue.isUndefinedOrNull() && connectOptsValue.isObject()) {
if (JSC::JSObject* connectOptsObj = connectOptsValue.getObject()) {
// Extract rejectUnauthorized
auto rejectValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "rejectUnauthorized"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (rejectValue && rejectValue.isBoolean()) {
rejectUnauthorized = rejectValue.asBoolean() ? 1 : 0;
}
// Build filtered TLS options object with only supported properties
JSC::JSObject* filteredTlsOpts = JSC::constructEmptyObject(globalObject);
bool hasTlsOpts = false;
auto caValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "ca"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (caValue && !caValue.isUndefinedOrNull()) {
filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "ca"_s), caValue);
hasTlsOpts = true;
}
auto certValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "cert"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (certValue && !certValue.isUndefinedOrNull()) {
filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "cert"_s), certValue);
hasTlsOpts = true;
}
auto keyValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "key"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (keyValue && !keyValue.isUndefinedOrNull()) {
filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "key"_s), keyValue);
hasTlsOpts = true;
}
auto passphraseValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "passphrase"_s)));
RETURN_IF_EXCEPTION(throwScope, {});
if (passphraseValue && !passphraseValue.isUndefinedOrNull()) {
filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "passphrase"_s), passphraseValue);
hasTlsOpts = true;
}
// Parse the filtered TLS options
if (hasTlsOpts) {
sslConfig = Bun__WebSocket__parseSSLConfig(globalObject, JSValue::encode(filteredTlsOpts));
RETURN_IF_EXCEPTION(throwScope, {});
}
}
}
}
}
@@ -258,10 +429,13 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG
}
}
auto object = (rejectUnauthorized == -1) ? WebSocket::create(*context, WTF::move(url), protocols, WTF::move(headersInit)) : WebSocket::create(*context, WTF::move(url), protocols, WTF::move(headersInit), rejectUnauthorized ? true : false);
auto object = (rejectUnauthorized == -1)
? WebSocket::create(*context, WTF::move(url), protocols, WTF::move(headersInit), WTF::move(proxyUrl), WTF::move(proxyHeadersInit), sslConfig)
: WebSocket::create(*context, WTF::move(url), protocols, WTF::move(headersInit), rejectUnauthorized ? true : false, WTF::move(proxyUrl), WTF::move(proxyHeadersInit), sslConfig);
if constexpr (IsExceptionOr<decltype(object)>)
RETURN_IF_EXCEPTION(throwScope, {});
static_assert(TypeOrExceptionOrUnderlyingType<decltype(object)>::isRef);
auto jsValue = toJSNewlyCreated<IDLInterface<WebSocket>>(*lexicalGlobalObject, *globalObject, throwScope, WTF::move(object));
if constexpr (IsExceptionOr<decltype(object)>)

View File

@@ -36,6 +36,7 @@
#include "blob.h"
#include "ZigGeneratedClasses.h"
#include "CloseEvent.h"
#include <wtf/text/Base64.h>
// #include "ContentSecurityPolicy.h"
// #include "DOMWindow.h"
// #include "Document.h"
@@ -190,7 +191,9 @@ WebSocket::~WebSocket()
{
if (m_upgradeClient != nullptr) {
void* upgradeClient = m_upgradeClient;
if (m_isSecure) {
// Use TLS cancel if connection type is TLS or ProxyTLS (either is a TLS socket to the remote)
bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS);
if (useTLSClient) {
Bun__WebSocketHTTPSClient__cancel(reinterpret_cast<void*>(upgradeClient));
} else {
Bun__WebSocketHTTPClient__cancel(reinterpret_cast<void*>(upgradeClient));
@@ -220,6 +223,74 @@ WebSocket::~WebSocket()
}
}
// Transient proxy configuration - used only during connect() and not stored as member fields
struct ProxyConfig {
String host;
uint16_t port { 0 };
String authorization;
Vector<std::pair<String, String>> headers;
bool isHTTPS { false };
};
static ExceptionOr<std::optional<ProxyConfig>> setupProxy(const String& proxyUrl, std::optional<FetchHeaders::Init>&& proxyHeaders)
{
if (proxyUrl.isNull() || proxyUrl.isEmpty())
return { std::nullopt };
URL url { proxyUrl };
if (!url.isValid())
return Exception { SyntaxError, makeString("Invalid proxy URL: "_s, proxyUrl) };
ProxyConfig config;
config.host = url.host().toString();
config.isHTTPS = url.protocolIs("https"_s);
config.port = url.port().value_or(config.isHTTPS ? 443 : 80);
// Compute Basic auth from proxy URL credentials
if (!url.user().isEmpty()) {
auto credentials = makeString(url.user(), ':', url.password());
auto utf8 = credentials.utf8();
auto encoded = base64EncodeToString(std::span<const uint8_t>(reinterpret_cast<const uint8_t*>(utf8.data()), utf8.length()));
config.authorization = makeString("Basic "_s, encoded);
}
// Store proxy headers
if (proxyHeaders) {
auto headersOrException = FetchHeaders::create(WTF::move(proxyHeaders));
if (!headersOrException.hasException()) {
auto hdrs = headersOrException.releaseReturnValue();
auto iterator = hdrs.get().createIterator(false);
while (auto value = iterator.next()) {
config.headers.append({ value->key, value->value });
}
}
}
return { WTF::move(config) };
}
void WebSocket::setExtensionsFromDeflateParams(const PerMessageDeflateParams* deflate_params)
{
if (deflate_params == nullptr)
return;
StringBuilder extensions;
extensions.append("permessage-deflate"_s);
if (deflate_params->server_no_context_takeover)
extensions.append("; server_no_context_takeover"_s);
if (deflate_params->client_no_context_takeover)
extensions.append("; client_no_context_takeover"_s);
if (deflate_params->server_max_window_bits != 15) {
extensions.append("; server_max_window_bits="_s);
extensions.append(String::number(deflate_params->server_max_window_bits));
}
if (deflate_params->client_max_window_bits != 15) {
extensions.append("; client_max_window_bits="_s);
extensions.append(String::number(deflate_params->client_max_window_bits));
}
m_extensions = extensions.toString();
}
ExceptionOr<Ref<WebSocket>> WebSocket::create(ScriptExecutionContext& context, const String& url)
{
return create(context, url, Vector<String> {}, std::nullopt);
@@ -264,6 +335,45 @@ ExceptionOr<Ref<WebSocket>> WebSocket::create(ScriptExecutionContext& context, c
return socket;
}
ExceptionOr<Ref<WebSocket>> WebSocket::create(ScriptExecutionContext& context, const String& url, const Vector<String>& protocols, std::optional<FetchHeaders::Init>&& headers, const String& proxyUrl, std::optional<FetchHeaders::Init>&& proxyHeaders, void* sslConfig)
{
if (url.isNull())
return Exception { SyntaxError };
auto proxyConfigResult = setupProxy(proxyUrl, WTF::move(proxyHeaders));
if (proxyConfigResult.hasException())
return proxyConfigResult.releaseException();
auto socket = adoptRef(*new WebSocket(context));
socket->m_sslConfig = sslConfig; // Set BEFORE connect() so it's available during connection
auto result = socket->connect(url, protocols, WTF::move(headers), proxyConfigResult.releaseReturnValue());
if (result.hasException())
return result.releaseException();
return socket;
}
ExceptionOr<Ref<WebSocket>> WebSocket::create(ScriptExecutionContext& context, const String& url, const Vector<String>& protocols, std::optional<FetchHeaders::Init>&& headers, bool rejectUnauthorized, const String& proxyUrl, std::optional<FetchHeaders::Init>&& proxyHeaders, void* sslConfig)
{
if (url.isNull())
return Exception { SyntaxError };
auto proxyConfigResult = setupProxy(proxyUrl, WTF::move(proxyHeaders));
if (proxyConfigResult.hasException())
return proxyConfigResult.releaseException();
auto socket = adoptRef(*new WebSocket(context));
socket->setRejectUnauthorized(rejectUnauthorized);
socket->m_sslConfig = sslConfig; // Set BEFORE connect() so it's available during connection
auto result = socket->connect(url, protocols, WTF::move(headers), proxyConfigResult.releaseReturnValue());
if (result.hasException())
return result.releaseException();
return socket;
}
ExceptionOr<Ref<WebSocket>> WebSocket::create(ScriptExecutionContext& context, const String& url, const String& protocol)
{
return create(context, url, Vector<String> { 1, protocol });
@@ -304,6 +414,11 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
return connect(url, protocols, std::nullopt);
}
ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& protocols, std::optional<FetchHeaders::Init>&& headersInit)
{
return connect(url, protocols, WTF::move(headersInit), std::nullopt);
}
size_t WebSocket::memoryCost() const
{
@@ -319,7 +434,9 @@ size_t WebSocket::memoryCost() const
}
if (m_upgradeClient) {
if (m_isSecure) {
// Use TLS cost if connection type is TLS or ProxyTLS
bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS);
if (useTLSClient) {
cost += Bun__WebSocketHTTPSClient__memoryCost(m_upgradeClient);
} else {
cost += Bun__WebSocketHTTPClient__memoryCost(m_upgradeClient);
@@ -329,7 +446,7 @@ size_t WebSocket::memoryCost() const
return cost;
}
ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& protocols, std::optional<FetchHeaders::Init>&& headersInit)
ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& protocols, std::optional<FetchHeaders::Init>&& headersInit, std::optional<ProxyConfig>&& proxyConfig)
{
// LOG(Network, "WebSocket %p connect() url='%s'", this, url.utf8().data());
m_url = URL { url };
@@ -454,19 +571,73 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
headerValues.unsafeAppendWithoutCapacityCheck(Zig::toZigString(value->value));
}
m_isSecure = is_secure;
// Determine connection type based on proxy usage and TLS requirements
bool hasProxy = proxyConfig.has_value();
bool proxyIsHTTPS = hasProxy && proxyConfig->isHTTPS;
// Connection type determines what kind of socket we use:
// - Plain/TLS: direct connection, socket type matches target protocol
// - ProxyPlain/ProxyTLS: through proxy, socket type matches PROXY protocol (not target)
if (hasProxy) {
m_connectionType = proxyIsHTTPS ? ConnectionType::ProxyTLS : ConnectionType::ProxyPlain;
} else {
m_connectionType = is_secure ? ConnectionType::TLS : ConnectionType::Plain;
}
this->incPendingActivityCount();
if (is_secure) {
// Prepare proxy parameters (use local variables, not member fields)
ZigString proxyHost = hasProxy ? Zig::toZigString(proxyConfig->host) : ZigString {};
ZigString proxyAuth = hasProxy ? Zig::toZigString(proxyConfig->authorization) : ZigString {};
uint16_t proxyPort = hasProxy ? proxyConfig->port : 0;
Vector<ZigString, 8> proxyHeaderNames;
Vector<ZigString, 8> proxyHeaderValues;
if (hasProxy) {
proxyHeaderNames.reserveInitialCapacity(proxyConfig->headers.size());
proxyHeaderValues.reserveInitialCapacity(proxyConfig->headers.size());
for (const auto& header : proxyConfig->headers) {
proxyHeaderNames.unsafeAppendWithoutCapacityCheck(Zig::toZigString(header.first));
proxyHeaderValues.unsafeAppendWithoutCapacityCheck(Zig::toZigString(header.second));
}
}
// Pass SSLConfig pointer to Zig (ownership transferred - Zig will deinit when connection closes)
// After this call, m_sslConfig should not be used by C++ anymore
void* sslConfig = m_sslConfig;
m_sslConfig = nullptr; // Transfer ownership
// Use TLS client based on connection type:
// - TLS/ProxyTLS: use TLS socket
// - Plain/ProxyPlain: use plain socket
bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS);
if (useTLSClient) {
us_socket_context_t* ctx = scriptExecutionContext()->webSocketContext<true>();
RELEASE_ASSERT(ctx);
this->m_upgradeClient = Bun__WebSocketHTTPSClient__connect(scriptExecutionContext()->jsGlobalObject(), ctx, reinterpret_cast<CppWebSocket*>(this), &host, port, &path, &clientProtocolString, headerNames.begin(), headerValues.begin(), headerNames.size());
this->m_upgradeClient = Bun__WebSocketHTTPSClient__connect(
scriptExecutionContext()->jsGlobalObject(), ctx, reinterpret_cast<CppWebSocket*>(this),
&host, port, &path, &clientProtocolString,
headerNames.begin(), headerValues.begin(), headerNames.size(),
hasProxy ? &proxyHost : nullptr, proxyPort,
(hasProxy && !proxyConfig->authorization.isEmpty()) ? &proxyAuth : nullptr,
proxyHeaderNames.begin(), proxyHeaderValues.begin(), proxyHeaderNames.size(),
sslConfig, is_secure);
} else {
us_socket_context_t* ctx = scriptExecutionContext()->webSocketContext<false>();
RELEASE_ASSERT(ctx);
this->m_upgradeClient = Bun__WebSocketHTTPClient__connect(scriptExecutionContext()->jsGlobalObject(), ctx, reinterpret_cast<CppWebSocket*>(this), &host, port, &path, &clientProtocolString, headerNames.begin(), headerValues.begin(), headerNames.size());
this->m_upgradeClient = Bun__WebSocketHTTPClient__connect(
scriptExecutionContext()->jsGlobalObject(), ctx, reinterpret_cast<CppWebSocket*>(this),
&host, port, &path, &clientProtocolString,
headerNames.begin(), headerValues.begin(), headerNames.size(),
hasProxy ? &proxyHost : nullptr, proxyPort,
(hasProxy && !proxyConfig->authorization.isEmpty()) ? &proxyAuth : nullptr,
proxyHeaderNames.begin(), proxyHeaderValues.begin(), proxyHeaderNames.size(),
sslConfig, is_secure);
}
proxyHeaderValues.clear();
proxyHeaderNames.clear();
headerValues.clear();
headerNames.clear();
@@ -673,7 +844,8 @@ ExceptionOr<void> WebSocket::close(std::optional<unsigned short> optionalCode, c
if (m_upgradeClient != nullptr) {
void* upgradeClient = m_upgradeClient;
m_upgradeClient = nullptr;
if (m_isSecure) {
bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS);
if (useTLSClient) {
Bun__WebSocketHTTPSClient__cancel(upgradeClient);
} else {
Bun__WebSocketHTTPClient__cancel(upgradeClient);
@@ -728,7 +900,8 @@ ExceptionOr<void> WebSocket::terminate()
if (m_upgradeClient != nullptr) {
void* upgradeClient = m_upgradeClient;
m_upgradeClient = nullptr;
if (m_isSecure) {
bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS);
if (useTLSClient) {
Bun__WebSocketHTTPSClient__cancel(upgradeClient);
} else {
Bun__WebSocketHTTPClient__cancel(upgradeClient);
@@ -1297,43 +1470,30 @@ void WebSocket::didClose(unsigned unhandledBufferedAmount, unsigned short code,
this->disablePendingActivity();
}
void WebSocket::didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params)
void WebSocket::didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx)
{
this->m_upgradeClient = nullptr;
setExtensionsFromDeflateParams(deflate_params);
// Set extensions if permessage-deflate was negotiated
if (deflate_params != nullptr) {
StringBuilder extensions;
extensions.append("permessage-deflate"_s);
if (deflate_params->server_no_context_takeover) {
extensions.append("; server_no_context_takeover"_s);
}
if (deflate_params->client_no_context_takeover) {
extensions.append("; client_no_context_takeover"_s);
}
if (deflate_params->server_max_window_bits != 15) {
extensions.append("; server_max_window_bits="_s);
extensions.append(String::number(deflate_params->server_max_window_bits));
}
if (deflate_params->client_max_window_bits != 15) {
extensions.append("; client_max_window_bits="_s);
extensions.append(String::number(deflate_params->client_max_window_bits));
}
this->m_extensions = extensions.toString();
}
// Use TLS WebSocket client if connection type is TLS or ProxyTLS.
// For TLS: direct wss:// connection, socket is already TLS.
// For ProxyTLS: connected through HTTPS proxy, socket is TLS (even for ws:// target).
// For Plain/ProxyPlain: socket is not TLS.
bool useTLSSocket = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS);
if (m_isSecure) {
if (useTLSSocket) {
us_socket_context_t* ctx = (us_socket_context_t*)this->scriptExecutionContext()->connectedWebSocketContext<true, false>();
this->m_connectedWebSocket.clientSSL = Bun__WebSocketClientTLS__init(reinterpret_cast<CppWebSocket*>(this), socket, ctx, this->scriptExecutionContext()->jsGlobalObject(), reinterpret_cast<unsigned char*>(bufferedData), bufferedDataSize, deflate_params);
this->m_connectedWebSocket.clientSSL = Bun__WebSocketClientTLS__init(reinterpret_cast<CppWebSocket*>(this), socket, ctx, this->scriptExecutionContext()->jsGlobalObject(), reinterpret_cast<unsigned char*>(bufferedData), bufferedDataSize, deflate_params, customSSLCtx);
this->m_connectedWebSocketKind = ConnectedWebSocketKind::ClientSSL;
} else {
us_socket_context_t* ctx = (us_socket_context_t*)this->scriptExecutionContext()->connectedWebSocketContext<false, false>();
this->m_connectedWebSocket.client = Bun__WebSocketClient__init(reinterpret_cast<CppWebSocket*>(this), socket, ctx, this->scriptExecutionContext()->jsGlobalObject(), reinterpret_cast<unsigned char*>(bufferedData), bufferedDataSize, deflate_params);
this->m_connectedWebSocket.client = Bun__WebSocketClient__init(reinterpret_cast<CppWebSocket*>(this), socket, ctx, this->scriptExecutionContext()->jsGlobalObject(), reinterpret_cast<unsigned char*>(bufferedData), bufferedDataSize, deflate_params, customSSLCtx);
this->m_connectedWebSocketKind = ConnectedWebSocketKind::Client;
}
this->didConnect();
}
void WebSocket::didFailWithErrorCode(Bun::WebSocketErrorCode code)
{
// from new WebSocket() -> connect()
@@ -1479,6 +1639,22 @@ void WebSocket::didFailWithErrorCode(Bun::WebSocketErrorCode code)
didReceiveClose(CleanStatus::NotClean, 1002, "Invalid compressed data"_s);
break;
}
case Bun::WebSocketErrorCode::proxy_connect_failed: {
didReceiveClose(CleanStatus::NotClean, 1006, "Proxy connection failed"_s, true);
break;
}
case Bun::WebSocketErrorCode::proxy_authentication_required: {
didReceiveClose(CleanStatus::NotClean, 1006, "Proxy authentication required"_s, true);
break;
}
case Bun::WebSocketErrorCode::proxy_connection_refused: {
didReceiveClose(CleanStatus::NotClean, 1006, "Proxy connection refused"_s, true);
break;
}
case Bun::WebSocketErrorCode::proxy_tunnel_failed: {
didReceiveClose(CleanStatus::NotClean, 1006, "Proxy tunnel failed"_s, true);
break;
}
}
m_state = CLOSED;
@@ -1504,12 +1680,48 @@ void WebSocket::updateHasPendingActivity()
!(m_state == CLOSED && m_pendingActivityCount == 0));
}
// Forward declarations for tunnel mode (defined outside namespace)
extern "C" void* Bun__WebSocketClient__initWithTunnel(CppWebSocket* ws, void* tunnel, JSC::JSGlobalObject* globalObject, unsigned char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params);
extern "C" void WebSocketProxyTunnel__setConnectedWebSocket(void* tunnel, void* websocket);
void WebSocket::didConnectWithTunnel(void* tunnel, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params)
{
this->m_upgradeClient = nullptr;
setExtensionsFromDeflateParams(deflate_params);
// For wss:// through HTTP proxy, we use a plain (non-TLS) WebSocket client
// because the TLS is handled by the proxy tunnel
this->m_connectedWebSocket.client = Bun__WebSocketClient__initWithTunnel(
reinterpret_cast<CppWebSocket*>(this),
tunnel,
this->scriptExecutionContext()->jsGlobalObject(),
reinterpret_cast<unsigned char*>(bufferedData),
bufferedDataSize,
deflate_params);
this->m_connectedWebSocketKind = ConnectedWebSocketKind::Client;
// IMPORTANT: Call didConnect() BEFORE setting the connected websocket on the tunnel.
// didConnect() sets m_state = OPEN, and messages are dropped if state != OPEN.
// By calling didConnect() first, we ensure the state is OPEN before the tunnel
// starts forwarding messages to the WebSocket client.
this->didConnect();
// Now set the connected websocket on the tunnel to start forwarding data
WebSocketProxyTunnel__setConnectedWebSocket(tunnel, this->m_connectedWebSocket.client);
}
} // namespace WebCore
extern "C" void WebSocket__didConnect(WebCore::WebSocket* webSocket, us_socket_t* socket, char* bufferedData, size_t len, const PerMessageDeflateParams* deflate_params)
extern "C" void WebSocket__didConnect(WebCore::WebSocket* webSocket, us_socket_t* socket, char* bufferedData, size_t len, const PerMessageDeflateParams* deflate_params, void* customSSLCtx)
{
webSocket->didConnect(socket, bufferedData, len, deflate_params);
webSocket->didConnect(socket, bufferedData, len, deflate_params, customSSLCtx);
}
extern "C" void WebSocket__didConnectWithTunnel(WebCore::WebSocket* webSocket, void* tunnel, char* bufferedData, size_t len, const PerMessageDeflateParams* deflate_params)
{
webSocket->didConnectWithTunnel(tunnel, bufferedData, len, deflate_params);
}
extern "C" void WebSocket__didAbruptClose(WebCore::WebSocket* webSocket, Bun::WebSocketErrorCode errorCode)
{
webSocket->didFailWithErrorCode(errorCode);

View File

@@ -68,6 +68,9 @@ public:
static ExceptionOr<Ref<WebSocket>> create(ScriptExecutionContext&, const String& url, const Vector<String>& protocols);
static ExceptionOr<Ref<WebSocket>> create(ScriptExecutionContext&, const String& url, const Vector<String>& protocols, std::optional<FetchHeaders::Init>&&);
static ExceptionOr<Ref<WebSocket>> create(ScriptExecutionContext& context, const String& url, const Vector<String>& protocols, std::optional<FetchHeaders::Init>&& headers, bool rejectUnauthorized);
// With proxy support
static ExceptionOr<Ref<WebSocket>> create(ScriptExecutionContext&, const String& url, const Vector<String>& protocols, std::optional<FetchHeaders::Init>&&, const String& proxyUrl, std::optional<FetchHeaders::Init>&& proxyHeaders, void* sslConfig);
static ExceptionOr<Ref<WebSocket>> create(ScriptExecutionContext& context, const String& url, const Vector<String>& protocols, std::optional<FetchHeaders::Init>&& headers, bool rejectUnauthorized, const String& proxyUrl, std::optional<FetchHeaders::Init>&& proxyHeaders, void* sslConfig);
~WebSocket();
enum State {
@@ -91,10 +94,21 @@ public:
Clean = 1,
};
// Tracks the connection type for both the upgrade client and the connected websocket.
// This replaces separate m_isSecure and m_proxyIsHTTPS bools.
enum class ConnectionType : uint8_t {
Plain, // ws:// direct connection
TLS, // wss:// direct connection
ProxyPlain, // ws:// or wss:// through HTTP proxy (plain socket to proxy)
ProxyTLS // ws:// or wss:// through HTTPS proxy (TLS socket to proxy)
};
ExceptionOr<void> connect(const String& url);
ExceptionOr<void> connect(const String& url, const String& protocol);
ExceptionOr<void> connect(const String& url, const Vector<String>& protocols);
ExceptionOr<void> connect(const String& url, const Vector<String>& protocols, std::optional<FetchHeaders::Init>&&);
// Internal connect with proxy config (used by create() with proxy support)
ExceptionOr<void> connect(const String& url, const Vector<String>& protocols, std::optional<FetchHeaders::Init>&&, std::optional<struct ProxyConfig>&&);
ExceptionOr<void> send(const String& message);
ExceptionOr<void> send(JSC::ArrayBuffer&);
@@ -135,7 +149,8 @@ public:
void didConnect();
void disablePendingActivity();
void didClose(unsigned unhandledBufferedAmount, unsigned short code, const String& reason);
void didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params);
void didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx);
void didConnectWithTunnel(void* tunnel, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params);
void didFailWithErrorCode(Bun::WebSocketErrorCode code);
void didReceiveMessage(String&& message);
@@ -158,6 +173,16 @@ public:
return m_rejectUnauthorized;
}
void setSSLConfig(void* config)
{
m_sslConfig = config;
}
void* sslConfig() const
{
return m_sslConfig;
}
void incPendingActivityCount()
{
ASSERT(m_pendingActivityCount < std::numeric_limits<size_t>::max());
@@ -203,6 +228,7 @@ private:
void sendWebSocketString(const String& message, const Opcode opcode);
void sendWebSocketData(const char* data, size_t length, const Opcode opcode);
void setExtensionsFromDeflateParams(const PerMessageDeflateParams* deflate_params);
enum class BinaryType { Blob,
ArrayBuffer,
@@ -222,12 +248,15 @@ private:
String m_subprotocol;
String m_extensions;
void* m_upgradeClient { nullptr };
bool m_isSecure { false };
ConnectionType m_connectionType { ConnectionType::Plain };
bool m_rejectUnauthorized { false };
AnyWebSocket m_connectedWebSocket { nullptr };
ConnectedWebSocketKind m_connectedWebSocketKind { ConnectedWebSocketKind::None };
size_t m_pendingActivityCount { 0 };
// TLS options (SSLConfig pointer from Zig - ownership transferred to Zig)
void* m_sslConfig { nullptr };
bool m_dispatchedErrorEvent { false };
// RefPtr<PendingActivity<WebSocket>> m_pendingActivity;
};

View File

@@ -37,6 +37,11 @@ enum class WebSocketErrorCode : int32_t {
tls_handshake_failed = 30,
message_too_big = 31,
protocol_error = 32,
// Proxy error codes
proxy_connect_failed = 33,
proxy_authentication_required = 34,
proxy_connection_refused = 35,
proxy_tunnel_failed = 36,
};
}

View File

@@ -1291,7 +1291,7 @@ 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 sent = proxy.writeData(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose
const sent = proxy.write(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose
this.state.request_sent_len += sent;
this.state.request_body = this.state.request_body[sent..];
@@ -1346,7 +1346,7 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s
assert(!socket.isShutdown());
assert(!socket.isClosed());
}
const amount = proxy.writeData(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose
const amount = proxy.write(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose
if (comptime is_first_call) {
if (amount == 0) {
@@ -1606,7 +1606,7 @@ pub fn onData(
if (this.proxy_tunnel) |proxy| {
// if we have a tunnel we dont care about the other stages, we will just tunnel the data
this.setTimeout(socket, 5);
proxy.receiveData(incoming_data);
proxy.receive(incoming_data);
return;
}

View File

@@ -87,9 +87,7 @@ pub fn NewHTTPContext(comptime ssl: bool) type {
if (!comptime ssl) {
@compileError("ssl only");
}
var opts = client.tls_props.?.asUSockets();
opts.request_cert = 1;
opts.reject_unauthorized = 0;
const opts = client.tls_props.?.asUSocketsForClientVerification();
try this.initWithOpts(&opts);
}

View File

@@ -192,7 +192,7 @@ fn onHandshake(this: *HTTPClient, handshake_success: bool, ssl_error: uws.us_bun
}
}
pub fn write(this: *HTTPClient, encoded_data: []const u8) void {
pub fn writeEncrypted(this: *HTTPClient, encoded_data: []const u8) void {
if (this.proxy_tunnel) |proxy| {
// Preserve TLS record ordering: if any encrypted bytes are buffered,
// enqueue new bytes and flush them in FIFO via onWritable.
@@ -272,16 +272,14 @@ pub fn start(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is
.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;
// We always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match
const custom_options = ssl_options.forClientVerification();
proxy_tunnel.wrapper = SSLWrapper(*HTTPClient).init(custom_options, true, .{
.onOpen = ProxyTunnel.onOpen,
.onData = ProxyTunnel.onData,
.onHandshake = ProxyTunnel.onHandshake,
.onClose = ProxyTunnel.onClose,
.write = ProxyTunnel.write,
.write = ProxyTunnel.writeEncrypted,
.ctx = this,
}) catch |err| {
if (err == error.OutOfMemory) {
@@ -341,7 +339,7 @@ pub fn onWritable(this: *ProxyTunnel, comptime is_ssl: bool, socket: NewHTTPCont
}
}
pub fn receiveData(this: *ProxyTunnel, buf: []const u8) void {
pub fn receive(this: *ProxyTunnel, buf: []const u8) void {
this.ref();
defer this.deref();
if (this.wrapper) |*wrapper| {
@@ -349,7 +347,7 @@ pub fn receiveData(this: *ProxyTunnel, buf: []const u8) void {
}
}
pub fn writeData(this: *ProxyTunnel, buf: []const u8) !usize {
pub fn write(this: *ProxyTunnel, buf: []const u8) !usize {
if (this.wrapper) |*wrapper| {
return try wrapper.writeData(buf);
}

View File

@@ -54,6 +54,16 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
// Track compression state of the entire message (across fragments)
message_is_compressed: bool = false,
// Custom SSL context for per-connection TLS options (e.g., custom CA)
// This is set when the WebSocket is adopted from a connection that used a custom SSL context.
// Must be cleaned up when the WebSocket closes.
custom_ssl_ctx: ?*uws.SocketContext = null,
// Proxy tunnel for wss:// through HTTP proxy.
// When set, all I/O goes through the tunnel (TLS encryption/decryption).
// The tunnel handles the TLS layer, so this is used with ssl=false.
proxy_tunnel: ?*WebSocketProxyTunnel = null,
const stack_frame_size = 1024;
// Minimum message size to compress (RFC 7692 recommendation)
const MIN_COMPRESS_SIZE = 860;
@@ -117,6 +127,18 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
this.message_is_compressed = false;
if (this.deflate) |d| d.deinit();
this.deflate = null;
// Clean up custom SSL context if we own one
if (this.custom_ssl_ctx) |ctx| {
ctx.deinit(ssl);
this.custom_ssl_ctx = null;
}
// Clean up proxy tunnel if we own one
// Set to null FIRST to prevent re-entrancy (shutdown can trigger callbacks)
if (this.proxy_tunnel) |tunnel| {
this.proxy_tunnel = null;
tunnel.shutdown();
tunnel.deref();
}
}
pub fn cancel(this: *WebSocket) callconv(.c) void {
@@ -713,6 +735,16 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
socket: Socket,
bytes: []const u8,
) bool {
// For tunnel mode, write through the tunnel instead of direct socket
if (this.proxy_tunnel) |tunnel| {
// The tunnel handles TLS encryption and buffering
_ = tunnel.write(bytes) catch {
this.terminate(ErrorCode.failed_to_write);
return false;
};
return true;
}
// 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
@@ -987,6 +1019,8 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
_ = this.sendData(bytes, !this.hasBackpressure(), opcode);
}
fn hasTCP(this: *WebSocket) bool {
// For tunnel mode, we have an active connection through the tunnel
if (this.proxy_tunnel != null) return true;
return !this.tcp.isClosed() and !this.tcp.isShutdown();
}
@@ -1134,7 +1168,9 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
var ws = this.ws;
defer ws.unref();
if (this_socket.outgoing_websocket != null and !this_socket.tcp.isClosed()) {
// For tunnel mode, tcp is detached but connection is still active through the tunnel
const is_connected = !this_socket.tcp.isClosed() or this_socket.proxy_tunnel != null;
if (this_socket.outgoing_websocket != null and is_connected) {
this_socket.handleData(this_socket.tcp, this.slice);
}
}
@@ -1158,6 +1194,7 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
buffered_data: [*]u8,
buffered_data_len: usize,
deflate_params: ?*const WebSocketDeflate.Params,
custom_ssl_ctx_ptr: ?*anyopaque,
) callconv(.c) ?*anyopaque {
const tcp = @as(*uws.us_socket_t, @ptrCast(input_socket));
const ctx = @as(*uws.SocketContext, @ptrCast(socket_ctx));
@@ -1169,6 +1206,8 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
.send_buffer = bun.LinearFifo(u8, .Dynamic).init(bun.default_allocator),
.receive_buffer = bun.LinearFifo(u8, .Dynamic).init(bun.default_allocator),
.event_loop = globalThis.bunVM().eventLoop(),
// Take ownership of custom SSL context if provided
.custom_ssl_ctx = if (custom_ssl_ctx_ptr) |ptr| @ptrCast(ptr) else null,
});
if (deflate_params) |params| {
@@ -1220,6 +1259,69 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
);
}
/// Initialize a WebSocket client that uses a proxy tunnel for I/O.
/// Used for wss:// through HTTP proxy where TLS is handled by the tunnel.
/// The tunnel takes ownership of socket I/O, and this client reads/writes through it.
pub fn initWithTunnel(
outgoing: *CppWebSocket,
tunnel_ptr: *anyopaque,
globalThis: *jsc.JSGlobalObject,
buffered_data: [*]u8,
buffered_data_len: usize,
deflate_params: ?*const WebSocketDeflate.Params,
) callconv(.c) ?*anyopaque {
const tunnel: *WebSocketProxyTunnel = @ptrCast(@alignCast(tunnel_ptr));
var ws = bun.new(WebSocket, .{
.ref_count = .init(),
.tcp = .{ .socket = .{ .detached = {} } }, // No direct socket - using tunnel
.outgoing_websocket = outgoing,
.globalThis = globalThis,
.send_buffer = bun.LinearFifo(u8, .Dynamic).init(bun.default_allocator),
.receive_buffer = bun.LinearFifo(u8, .Dynamic).init(bun.default_allocator),
.event_loop = globalThis.bunVM().eventLoop(),
.proxy_tunnel = tunnel,
});
// Take ownership of the tunnel
tunnel.ref();
if (deflate_params) |params| {
if (WebSocketDeflate.init(bun.default_allocator, params.*, globalThis.bunVM().rareData())) |deflate| {
ws.deflate = deflate;
} else |_| {
ws.deflate = null;
}
}
bun.handleOom(ws.send_buffer.ensureTotalCapacity(2048));
bun.handleOom(ws.receive_buffer.ensureTotalCapacity(2048));
ws.poll_ref.ref(globalThis.bunVM());
const buffered_slice: []u8 = buffered_data[0..buffered_data_len];
if (buffered_slice.len > 0) {
const initial_data = InitialDataHandler.new(.{
.adopted = ws,
.slice = buffered_slice,
.ws = outgoing,
});
globalThis.queueMicrotaskCallback(initial_data, InitialDataHandler.handle);
outgoing.ref();
}
ws.ref();
return @as(*anyopaque, @ptrCast(ws));
}
/// Handle data received from the proxy tunnel (already decrypted).
/// Called by the WebSocketProxyTunnel when it receives and decrypts data.
pub fn handleTunnelData(this: *WebSocket, data: []const u8) void {
// Process the decrypted data as if it came from the socket
// hasTCP() now returns true for tunnel mode, so this will work correctly
this.handleData(this.tcp, data);
}
pub fn finalize(this: *WebSocket) callconv(.c) void {
log("finalize", .{});
this.clearData();
@@ -1261,6 +1363,7 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
@export(&close, .{ .name = "Bun__" ++ name ++ "__close" });
@export(&finalize, .{ .name = "Bun__" ++ name ++ "__finalize" });
@export(&init, .{ .name = "Bun__" ++ name ++ "__init" });
@export(&initWithTunnel, .{ .name = "Bun__" ++ name ++ "__initWithTunnel" });
@export(&memoryCost, .{ .name = "Bun__" ++ name ++ "__memoryCost" });
@export(&register, .{ .name = "Bun__" ++ name ++ "__register" });
@export(&writeBinaryData, .{ .name = "Bun__" ++ name ++ "__writeBinaryData" });
@@ -1304,6 +1407,11 @@ pub const ErrorCode = enum(i32) {
tls_handshake_failed = 30,
message_too_big = 31,
protocol_error = 32,
// Proxy error codes
proxy_connect_failed = 33,
proxy_authentication_required = 34,
proxy_connection_refused = 35,
proxy_tunnel_failed = 36,
};
pub const Mask = struct {
@@ -1565,6 +1673,7 @@ const log = Output.scoped(.WebSocketClient, .visible);
const string = []const u8;
const WebSocketDeflate = @import("./websocket_client/WebSocketDeflate.zig");
const WebSocketProxyTunnel = @import("./websocket_client/WebSocketProxyTunnel.zig");
const std = @import("std");
const CppWebSocket = @import("./websocket_client/CppWebSocket.zig").CppWebSocket;

View File

@@ -14,6 +14,14 @@ pub const CppWebSocket = opaque {
buffered_data: ?[*]u8,
buffered_len: usize,
deflate_params: ?*const WebSocketDeflate.Params,
custom_ssl_ctx: ?*uws.SocketContext,
) void;
extern fn WebSocket__didConnectWithTunnel(
websocket_context: *CppWebSocket,
tunnel: *anyopaque,
buffered_data: ?[*]u8,
buffered_len: usize,
deflate_params: ?*const WebSocketDeflate.Params,
) void;
extern fn WebSocket__didAbruptClose(websocket_context: *CppWebSocket, reason: ErrorCode) void;
extern fn WebSocket__didClose(websocket_context: *CppWebSocket, code: u16, reason: *const bun.String) void;
@@ -50,11 +58,17 @@ pub const CppWebSocket = opaque {
defer loop.exit();
return WebSocket__rejectUnauthorized(this);
}
pub fn didConnect(this: *CppWebSocket, socket: *uws.Socket, buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params) void {
pub fn didConnect(this: *CppWebSocket, socket: *uws.Socket, buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params, custom_ssl_ctx: ?*uws.SocketContext) void {
const loop = jsc.VirtualMachine.get().eventLoop();
loop.enter();
defer loop.exit();
WebSocket__didConnect(this, socket, buffered_data, buffered_len, deflate_params);
WebSocket__didConnect(this, socket, buffered_data, buffered_len, deflate_params, custom_ssl_ctx);
}
pub fn didConnectWithTunnel(this: *CppWebSocket, tunnel: *anyopaque, buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params) void {
const loop = jsc.VirtualMachine.get().eventLoop();
loop.enter();
defer loop.exit();
WebSocket__didConnectWithTunnel(this, tunnel, buffered_data, buffered_len, deflate_params);
}
extern fn WebSocket__incrementPendingActivity(websocket_context: *CppWebSocket) void;
extern fn WebSocket__decrementPendingActivity(websocket_context: *CppWebSocket) void;

View File

@@ -0,0 +1,71 @@
/// WebSocketProxy encapsulates proxy state for WebSocket connections through HTTP/HTTPS proxies.
/// This struct holds only the fields needed after the initial CONNECT request.
/// Fields like proxy_port, proxy_authorization, and proxy_headers are used
/// only during connect() and freed immediately after building the CONNECT request.
const WebSocketProxy = @This();
/// Target hostname for SNI during TLS handshake
#target_host: []const u8,
/// Whether target uses TLS (wss://)
#target_is_https: bool,
/// WebSocket upgrade request to send after CONNECT succeeds
#websocket_request_buf: []u8,
/// TLS tunnel for wss:// through HTTP proxy
#tunnel: ?*WebSocketProxyTunnel = null,
/// Initialize a new WebSocketProxy
pub fn init(
target_host: []const u8,
target_is_https: bool,
websocket_request_buf: []u8,
) WebSocketProxy {
return .{
.#target_host = target_host,
.#target_is_https = target_is_https,
.#websocket_request_buf = websocket_request_buf,
};
}
/// Get the target hostname for SNI during TLS handshake
pub fn getTargetHost(self: *const WebSocketProxy) []const u8 {
return self.#target_host;
}
/// Check if the target uses HTTPS (wss://)
pub fn isTargetHttps(self: *const WebSocketProxy) bool {
return self.#target_is_https;
}
/// Get the TLS tunnel for wss:// through HTTP proxy
pub fn getTunnel(self: *const WebSocketProxy) ?*WebSocketProxyTunnel {
return self.#tunnel;
}
/// Set the TLS tunnel
pub fn setTunnel(self: *WebSocketProxy, new_tunnel: ?*WebSocketProxyTunnel) void {
self.#tunnel = new_tunnel;
}
/// Take ownership of the WebSocket request buffer, clearing the internal reference.
/// The caller is responsible for freeing the returned buffer.
pub fn takeWebsocketRequestBuf(self: *WebSocketProxy) []u8 {
const buf = self.#websocket_request_buf;
self.#websocket_request_buf = &[_]u8{};
return buf;
}
/// Clean up all allocated resources
pub fn deinit(self: *WebSocketProxy) void {
bun.default_allocator.free(self.#target_host);
if (self.#websocket_request_buf.len > 0) {
bun.default_allocator.free(self.#websocket_request_buf);
}
if (self.#tunnel) |tunnel| {
self.#tunnel = null;
tunnel.shutdown();
tunnel.deref();
}
}
const WebSocketProxyTunnel = @import("./WebSocketProxyTunnel.zig");
const bun = @import("bun");

View File

@@ -0,0 +1,351 @@
/// WebSocketProxyTunnel handles TLS inside an HTTP CONNECT tunnel for wss:// through HTTP proxy.
///
/// This is used when connecting to a wss:// WebSocket server through an HTTP proxy.
/// The flow is:
/// 1. HTTP CONNECT request to proxy (handled by WebSocketUpgradeClient)
/// 2. Proxy responds with 200 Connection Established
/// 3. TLS handshake inside the tunnel (handled by this module using SSLWrapper)
/// 4. WebSocket upgrade request through the TLS tunnel
/// 5. WebSocket 101 response
/// 6. Hand off to WebSocket client
const WebSocketProxyTunnel = @This();
const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{});
pub const ref = RefCount.ref;
pub const deref = RefCount.deref;
/// Union type for upgrade client to maintain type safety.
/// The upgrade client can be either HTTP or HTTPS depending on the proxy connection.
pub const UpgradeClientUnion = union(enum) {
http: *NewHTTPUpgradeClient(false),
https: *NewHTTPUpgradeClient(true),
none: void,
pub fn handleDecryptedData(self: UpgradeClientUnion, data: []const u8) void {
switch (self) {
.http => |client| client.handleDecryptedData(data),
.https => |client| client.handleDecryptedData(data),
.none => {},
}
}
pub fn terminate(self: UpgradeClientUnion, code: ErrorCode) void {
switch (self) {
.http => |client| client.terminate(code),
.https => |client| client.terminate(code),
.none => {},
}
}
pub fn onProxyTLSHandshakeComplete(self: UpgradeClientUnion) void {
switch (self) {
.http => |client| client.onProxyTLSHandshakeComplete(),
.https => |client| client.onProxyTLSHandshakeComplete(),
.none => {},
}
}
pub fn isNone(self: UpgradeClientUnion) bool {
return self == .none;
}
};
const WebSocketClient = @import("../websocket_client.zig").NewWebSocketClient(false);
ref_count: RefCount,
/// Reference to the upgrade client (WebSocketUpgradeClient) - used during handshake phase
#upgrade_client: UpgradeClientUnion = .{ .none = {} },
/// Reference to the connected WebSocket client - used after successful upgrade
#connected_websocket: ?*WebSocketClient = null,
/// SSL wrapper for TLS inside tunnel
#wrapper: ?SSLWrapperType = null,
/// Socket reference (the proxy connection)
#socket: SocketUnion = .{ .none = {} },
/// Write buffer for encrypted data (maintains TLS record ordering)
#write_buffer: bun.io.StreamBuffer = .{},
/// Hostname for SNI (Server Name Indication)
#sni_hostname: ?[]const u8 = null,
/// Whether to reject unauthorized certificates
#reject_unauthorized: bool = true,
const SocketUnion = union(enum) {
tcp: uws.NewSocketHandler(false),
ssl: uws.NewSocketHandler(true),
none: void,
pub fn write(self: SocketUnion, data: []const u8) c_int {
return switch (self) {
.tcp => |s| s.write(data),
.ssl => |s| s.write(data),
.none => 0,
};
}
pub fn isClosed(self: SocketUnion) bool {
return switch (self) {
.tcp => |s| s.isClosed(),
.ssl => |s| s.isClosed(),
.none => true,
};
}
};
const SSLWrapperType = SSLWrapper(*WebSocketProxyTunnel);
/// Initialize a new proxy tunnel with all required parameters
pub fn init(
comptime ssl: bool,
upgrade_client: *NewHTTPUpgradeClient(ssl),
socket: uws.NewSocketHandler(ssl),
sni_hostname: []const u8,
reject_unauthorized: bool,
) !*WebSocketProxyTunnel {
return bun.new(WebSocketProxyTunnel, .{
.ref_count = .init(),
.#upgrade_client = if (comptime ssl) .{ .https = upgrade_client } else .{ .http = upgrade_client },
.#socket = if (comptime ssl) .{ .ssl = socket } else .{ .tcp = socket },
.#sni_hostname = try bun.default_allocator.dupe(u8, sni_hostname),
.#reject_unauthorized = reject_unauthorized,
});
}
fn deinit(this: *WebSocketProxyTunnel) void {
if (this.#wrapper) |*wrapper| {
wrapper.deinit();
this.#wrapper = null;
}
this.#write_buffer.deinit();
if (this.#sni_hostname) |hostname| {
bun.default_allocator.free(hostname);
this.#sni_hostname = null;
}
bun.destroy(this);
}
/// Start TLS handshake inside the tunnel
/// The ssl_options should contain all TLS configuration including CA certificates.
pub fn start(this: *WebSocketProxyTunnel, ssl_options: SSLConfig, initial_data: []const u8) !void {
// Allow handshake to complete so we can access peer certificate for manual
// hostname verification in onHandshake(). The actual reject_unauthorized
// check uses this.#reject_unauthorized field.
const options = ssl_options.forClientVerification();
this.#wrapper = try SSLWrapperType.init(options, true, .{
.ctx = this,
.onOpen = onOpen,
.onData = onData,
.onHandshake = onHandshake,
.onClose = onClose,
.write = writeEncrypted,
});
if (initial_data.len > 0) {
this.#wrapper.?.startWithPayload(initial_data);
} else {
this.#wrapper.?.start();
}
}
/// SSLWrapper callback: Called before TLS handshake starts
fn onOpen(this: *WebSocketProxyTunnel) void {
this.ref();
defer this.deref();
log("onOpen", .{});
// Configure SNI with hostname
if (this.#wrapper) |*wrapper| {
if (wrapper.ssl) |ssl_ptr| {
if (this.#sni_hostname) |hostname| {
if (!bun.strings.isIPAddress(hostname)) {
// Set SNI hostname
const hostname_z = bun.default_allocator.dupeZ(u8, hostname) catch return;
defer bun.default_allocator.free(hostname_z);
ssl_ptr.configureHTTPClient(hostname_z);
}
}
}
}
}
/// SSLWrapper callback: Called with decrypted data from the network
fn onData(this: *WebSocketProxyTunnel, decrypted_data: []const u8) void {
this.ref();
defer this.deref();
log("onData: {} bytes", .{decrypted_data.len});
if (decrypted_data.len == 0) return;
// If we have a connected WebSocket client, forward data to it
if (this.#connected_websocket) |ws| {
ws.handleTunnelData(decrypted_data);
return;
}
// Otherwise, forward to the upgrade client for WebSocket response processing
this.#upgrade_client.handleDecryptedData(decrypted_data);
}
/// SSLWrapper callback: Called after TLS handshake completes
fn onHandshake(this: *WebSocketProxyTunnel, success: bool, ssl_error: uws.us_bun_verify_error_t) void {
this.ref();
defer this.deref();
log("onHandshake: success={}", .{success});
if (this.#upgrade_client.isNone()) return;
if (!success) {
this.#upgrade_client.terminate(ErrorCode.tls_handshake_failed);
return;
}
// Check for SSL errors if we need to reject unauthorized
if (this.#reject_unauthorized) {
if (ssl_error.error_no != 0) {
this.#upgrade_client.terminate(ErrorCode.tls_handshake_failed);
return;
}
// Verify server identity
if (this.#wrapper) |*wrapper| {
if (wrapper.ssl) |ssl_ptr| {
if (this.#sni_hostname) |hostname| {
if (!BoringSSL.checkServerIdentity(ssl_ptr, hostname)) {
this.#upgrade_client.terminate(ErrorCode.tls_handshake_failed);
return;
}
}
}
}
}
// TLS handshake successful - notify client to send WebSocket upgrade
this.#upgrade_client.onProxyTLSHandshakeComplete();
}
/// SSLWrapper callback: Called when connection is closing
fn onClose(this: *WebSocketProxyTunnel) void {
this.ref();
defer this.deref();
log("onClose", .{});
// If we have a connected WebSocket client, notify it of the close
if (this.#connected_websocket) |ws| {
ws.fail(ErrorCode.ended);
return;
}
// Check if upgrade client is already cleaned up (prevents re-entrancy during cleanup)
if (this.#upgrade_client.isNone()) return;
// Otherwise notify the upgrade client
this.#upgrade_client.terminate(ErrorCode.ended);
}
/// Set the connected WebSocket client. Called after successful WebSocket upgrade.
/// This transitions the tunnel from upgrade phase to connected phase.
/// After calling this, decrypted data will be forwarded to the WebSocket client.
pub fn setConnectedWebSocket(this: *WebSocketProxyTunnel, ws: *WebSocketClient) void {
log("setConnectedWebSocket", .{});
this.#connected_websocket = ws;
// Clear the upgrade client reference since we're now in connected phase
this.#upgrade_client = .{ .none = {} };
}
/// SSLWrapper callback: Called with encrypted data to send to network
fn writeEncrypted(this: *WebSocketProxyTunnel, encrypted_data: []const u8) void {
log("writeEncrypted: {} bytes", .{encrypted_data.len});
// If data is already buffered, queue this to maintain TLS record ordering
if (this.#write_buffer.isNotEmpty()) {
bun.handleOom(this.#write_buffer.write(encrypted_data));
return;
}
// Try direct write to socket
const written = this.#socket.write(encrypted_data);
if (written < 0) {
// Write failed - buffer data for retry when socket becomes writable
bun.handleOom(this.#write_buffer.write(encrypted_data));
return;
}
// Buffer remaining data
const written_usize: usize = @intCast(written);
if (written_usize < encrypted_data.len) {
bun.handleOom(this.#write_buffer.write(encrypted_data[written_usize..]));
}
}
/// Called when the socket becomes writable - flush buffered encrypted data
pub fn onWritable(this: *WebSocketProxyTunnel) void {
this.ref();
defer this.deref();
// Flush the SSL state machine
if (this.#wrapper) |*wrapper| {
_ = wrapper.flush();
}
// Send buffered encrypted data
const to_send = this.#write_buffer.slice();
if (to_send.len == 0) return;
const written = this.#socket.write(to_send);
if (written < 0) return;
const written_usize: usize = @intCast(written);
if (written_usize == to_send.len) {
this.#write_buffer.reset();
} else {
this.#write_buffer.cursor += written_usize;
}
}
/// Feed encrypted data from the network to the SSL wrapper for decryption
pub fn receive(this: *WebSocketProxyTunnel, data: []const u8) void {
this.ref();
defer this.deref();
if (this.#wrapper) |*wrapper| {
wrapper.receiveData(data);
}
}
/// Write application data through the tunnel (will be encrypted)
pub fn write(this: *WebSocketProxyTunnel, data: []const u8) !usize {
if (this.#wrapper) |*wrapper| {
return try wrapper.writeData(data);
}
return error.ConnectionClosed;
}
/// Gracefully shutdown the TLS connection
pub fn shutdown(this: *WebSocketProxyTunnel) void {
if (this.#wrapper) |*wrapper| {
_ = wrapper.shutdown(true); // Fast shutdown
}
}
/// Check if the tunnel has backpressure
pub fn hasBackpressure(this: *const WebSocketProxyTunnel) bool {
return this.#write_buffer.isNotEmpty();
}
/// C export for setting the connected WebSocket client from C++
pub export fn WebSocketProxyTunnel__setConnectedWebSocket(tunnel: *WebSocketProxyTunnel, ws: *WebSocketClient) void {
tunnel.setConnectedWebSocket(ws);
}
const log = bun.Output.scoped(.WebSocketProxyTunnel, .visible);
const ErrorCode = @import("../websocket_client.zig").ErrorCode;
const NewHTTPUpgradeClient = @import("./WebSocketUpgradeClient.zig").NewHTTPUpgradeClient;
const SSLWrapper = @import("../../bun.js/api/bun/ssl_wrapper.zig").SSLWrapper;
const bun = @import("bun");
const BoringSSL = bun.BoringSSL;
const jsc = bun.jsc;
const uws = bun.uws;
const SSLConfig = jsc.API.ServerConfig.SSLConfig;

View File

@@ -43,10 +43,29 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
state: State = .initializing,
subprotocols: bun.StringSet,
const State = enum { initializing, reading, failed };
/// Proxy state (null when not using proxy)
proxy: ?WebSocketProxy = null,
// TLS options (full SSLConfig for complete TLS customization)
ssl_config: ?*SSLConfig = null,
// Custom SSL context for per-connection TLS options (e.g., custom CA)
// This is used when ssl_config has custom options that can't be applied
// to the shared SSL context from C++.
custom_ssl_ctx: ?*uws.SocketContext = null,
const State = enum {
initializing,
reading,
failed,
// Proxy states
proxy_handshake, // Sent CONNECT, waiting for 200
proxy_tls_handshake, // TLS inside tunnel (for wss:// through proxy)
done, // WebSocket upgrade complete, forwarding data through tunnel
};
const HTTPClient = @This();
pub fn register(_: *jsc.JSGlobalObject, _: *anyopaque, ctx: *uws.SocketContext) callconv(.c) void {
pub fn register(_: *jsc.JSGlobalObject, _: *uws.Loop, ctx: *uws.SocketContext) callconv(.c) void {
Socket.configure(
ctx,
true,
@@ -75,7 +94,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
/// Returning null signals to the parent function that the connection failed.
pub fn connect(
global: *jsc.JSGlobalObject,
socket_ctx: *anyopaque,
socket_ctx: *uws.SocketContext,
websocket: *CppWebSocket,
host: *const jsc.ZigString,
port: u16,
@@ -84,12 +103,24 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
header_names: ?[*]const jsc.ZigString,
header_values: ?[*]const jsc.ZigString,
header_count: usize,
// Proxy parameters
proxy_host: ?*const jsc.ZigString,
proxy_port: u16,
proxy_authorization: ?*const jsc.ZigString,
proxy_header_names: ?[*]const jsc.ZigString,
proxy_header_values: ?[*]const jsc.ZigString,
proxy_header_count: usize,
// TLS options (full SSLConfig for complete TLS customization)
ssl_config: ?*SSLConfig,
// Whether the target URL is wss:// (separate from ssl template parameter)
target_is_secure: bool,
) callconv(.c) ?*HTTPClient {
const vm = global.bunVM();
bun.assert(vm.event_loop_handle != null);
const extra_headers = NonUTF8Headers.init(header_names, header_values, header_count);
const using_proxy = proxy_host != null;
// Check if user provided a custom protocol for subprotocols validation
var protocol_for_subprotocols = client_protocol.*;
@@ -110,12 +141,71 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
extra_headers,
) catch return null;
// Build proxy state if using proxy
// The CONNECT request is built using local variables for proxy_authorization and proxy_headers
// which are freed immediately after building the request (not stored on the client).
var proxy_state: ?WebSocketProxy = null;
var connect_request: []u8 = &[_]u8{};
if (using_proxy) {
// Parse proxy authorization (temporary, freed after building CONNECT request)
var proxy_auth_slice: ?[]const u8 = null;
var proxy_auth_owned: ?[]u8 = null;
defer if (proxy_auth_owned) |auth| bun.default_allocator.free(auth);
if (proxy_authorization) |auth| {
proxy_auth_owned = bun.default_allocator.dupe(u8, auth.slice()) catch {
bun.default_allocator.free(body);
return null;
};
proxy_auth_slice = proxy_auth_owned;
}
// Parse proxy headers (temporary, freed after building CONNECT request)
var proxy_hdrs: ?Headers = null;
defer if (proxy_hdrs) |*hdrs| hdrs.deinit();
if (proxy_header_count > 0) {
const non_utf8_hdrs = NonUTF8Headers.init(proxy_header_names, proxy_header_values, proxy_header_count);
proxy_hdrs = non_utf8_hdrs.toHeaders(bun.default_allocator) catch {
bun.default_allocator.free(body);
return null;
};
}
// Build CONNECT request (proxy_auth and proxy_hdrs are freed by defer after this)
connect_request = buildConnectRequest(
host.slice(),
port,
proxy_auth_slice,
proxy_hdrs,
) catch {
bun.default_allocator.free(body);
return null;
};
// Duplicate target_host (needed for SNI during TLS handshake)
const target_host_dup = bun.default_allocator.dupe(u8, host.slice()) catch {
bun.default_allocator.free(body);
bun.default_allocator.free(connect_request);
return null;
};
proxy_state = WebSocketProxy.init(
target_host_dup,
// Use target_is_secure from C++, not ssl template parameter
// (ssl may be true for HTTPS proxy even with ws:// target)
target_is_secure,
body,
);
}
var client = bun.new(HTTPClient, .{
.ref_count = .init(),
.tcp = .{ .socket = .{ .detached = {} } },
.outgoing_websocket = websocket,
.input_body_buf = body,
.input_body_buf = if (using_proxy) connect_request else body,
.state = .initializing,
.proxy = proxy_state,
.subprotocols = brk: {
var subprotocols = bun.StringSet.init(bun.default_allocator);
var it = bun.http.HeaderValueIterator.init(protocol_for_subprotocols.slice());
@@ -126,9 +216,14 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
},
});
var host_ = host.toSlice(bun.default_allocator);
// Store TLS config if provided (ownership transferred to client)
client.ssl_config = ssl_config;
var host_ = if (using_proxy) proxy_host.?.toSlice(bun.default_allocator) else host.toSlice(bun.default_allocator);
defer host_.deinit();
const connect_port = if (using_proxy) proxy_port else port;
client.poll_ref.ref(vm);
const display_host_ = host_.slice();
const display_host = if (bun.FeatureFlags.hardcode_localhost_to_127_0_0_1 and strings.eqlComptime(display_host_, "localhost"))
@@ -136,10 +231,60 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
else
display_host_;
// For TLS connections with custom SSLConfig (e.g., custom CA), create a per-connection
// SSL context instead of using the shared context from C++. This is needed because:
// - The shared context is created once with default settings (no custom CA)
// - Custom CA certificates must be loaded at context creation time
// - This applies to both direct wss:// and HTTPS proxy connections
var connect_ctx: *uws.SocketContext = socket_ctx;
log("connect: ssl={}, has_ssl_config={}, using_proxy={}", .{ ssl, ssl_config != null, using_proxy });
if (comptime ssl) {
if (ssl_config) |config| {
if (config.requires_custom_request_ctx) {
const ctx_opts = config.asUSocketsForClientVerification();
var err: uws.create_bun_socket_error_t = .none;
if (uws.SocketContext.createSSLContext(
vm.uwsLoop(),
@sizeOf(usize),
ctx_opts,
&err,
)) |custom_ctx| {
// Configure the custom context with the same callbacks as the shared context
Socket.configure(
custom_ctx,
true,
*HTTPClient,
struct {
pub const onOpen = handleOpen;
pub const onClose = handleClose;
pub const onData = handleData;
pub const onWritable = handleWritable;
pub const onTimeout = handleTimeout;
pub const onLongTimeout = handleTimeout;
pub const onConnectError = handleConnectError;
pub const onEnd = handleEnd;
pub const onHandshake = handleHandshake;
},
);
client.custom_ssl_ctx = custom_ctx;
connect_ctx = custom_ctx;
log("Created custom SSL context for TLS connection with custom CA", .{});
} else {
// Failed to create custom context, fall back to shared context
// The connection may still work if the CA isn't needed
log("Failed to create custom SSL context: {s}", .{@tagName(err)});
}
}
}
}
if (Socket.connectPtr(
display_host,
port,
@as(*uws.SocketContext, @ptrCast(socket_ctx)),
connect_port,
connect_ctx,
HTTPClient,
client,
"tcp",
@@ -180,6 +325,21 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
this.subprotocols.clearAndFree();
this.clearInput();
this.body.clearAndFree(bun.default_allocator);
// Clean up proxy state
if (this.proxy) |*p| {
p.deinit();
this.proxy = null;
}
if (this.ssl_config) |config| {
config.deinit();
bun.default_allocator.destroy(config);
this.ssl_config = null;
}
if (this.custom_ssl_ctx) |ctx| {
ctx.deinit(ssl);
this.custom_ssl_ctx = null;
}
}
pub fn cancel(this: *HTTPClient) callconv(.c) void {
this.clearData();
@@ -189,7 +349,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
this.ref();
defer this.deref();
// The C++ end of the socket is no longer holding a reference to this, sowe must clear it.
// The C++ end of the socket is no longer holding a reference to this, so we must clear it.
if (this.outgoing_websocket != null) {
this.outgoing_websocket = null;
this.deref();
@@ -206,10 +366,6 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
pub fn fail(this: *HTTPClient, code: ErrorCode) void {
log("onFail: {s}", .{@tagName(code)});
jsc.markBinding(@src());
this.ref();
defer this.deref();
this.dispatchAbruptClose(code);
if (comptime ssl) {
@@ -244,7 +400,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
}
pub fn handleHandshake(this: *HTTPClient, socket: Socket, success: i32, ssl_error: uws.us_bun_verify_error_t) void {
log("onHandshake({d})", .{success});
log("onHandshake({d}) ssl_error.error_no={d}", .{ success, ssl_error.error_no });
const handshake_success = if (success == 1) true else false;
var reject_unauthorized = false;
@@ -257,6 +413,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
if (reject_unauthorized) {
// only reject the connection if reject_unauthorized == true
if (ssl_error.error_no != 0) {
log("TLS handshake failed: ssl_error={d}, has_custom_ctx={}", .{ ssl_error.error_no, this.custom_ssl_ctx != null });
this.fail(ErrorCode.tls_handshake_failed);
return;
}
@@ -284,13 +441,19 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
if (comptime ssl) {
if (this.hostname.len > 0) {
socket.getNativeHandle().?.configureHTTPClient(this.hostname);
if (socket.getNativeHandle()) |handle| {
handle.configureHTTPClient(this.hostname);
}
bun.default_allocator.free(this.hostname);
this.hostname = "";
}
}
// Do not set MSG_MORE, see https://github.com/oven-sh/bun/issues/4010
// If using proxy, set state to proxy_handshake
if (this.proxy != null) {
this.state = .proxy_handshake;
}
const wrote = socket.write(this.input_body_buf);
if (wrote < 0) {
this.terminate(ErrorCode.failed_to_write);
@@ -306,7 +469,24 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
pub fn handleData(this: *HTTPClient, socket: Socket, data: []const u8) void {
log("onData", .{});
// For tunnel mode after successful upgrade, forward all data to the tunnel
// The tunnel will decrypt and pass to the WebSocket client
if (this.state == .done) {
if (this.proxy) |*p| {
if (p.getTunnel()) |tunnel| {
// Ref the tunnel to keep it alive during this call
// (in case the WebSocket client closes during processing)
tunnel.ref();
defer tunnel.deref();
tunnel.receive(data);
}
}
return;
}
if (this.outgoing_websocket == null) {
this.state = .failed;
this.clearData();
socket.close(.failure);
return;
@@ -319,6 +499,225 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
if (comptime Environment.allow_assert)
bun.assert(!socket.isShutdown());
// Handle proxy handshake response
if (this.state == .proxy_handshake) {
this.handleProxyResponse(socket, data);
return;
}
// Route through proxy tunnel if TLS handshake is in progress or complete
if (this.proxy) |*p| {
if (p.getTunnel()) |tunnel| {
tunnel.receive(data);
return;
}
}
var body = data;
if (this.body.items.len > 0) {
bun.handleOom(this.body.appendSlice(bun.default_allocator, data));
body = this.body.items;
}
const is_first = this.body.items.len == 0;
const http_101 = "HTTP/1.1 101 ";
if (is_first and body.len > http_101.len) {
// fail early if we receive a non-101 status code
if (!strings.hasPrefixComptime(body, http_101)) {
this.terminate(ErrorCode.expected_101_status_code);
return;
}
}
const response = PicoHTTP.Response.parse(body, &this.headers_buf) catch |err| {
switch (err) {
error.Malformed_HTTP_Response => {
this.terminate(ErrorCode.invalid_response);
return;
},
error.ShortRead => {
if (this.body.items.len == 0) {
bun.handleOom(this.body.appendSlice(bun.default_allocator, data));
}
return;
},
}
};
this.processResponse(response, body[@as(usize, @intCast(response.bytes_read))..]);
}
fn handleProxyResponse(this: *HTTPClient, socket: Socket, data: []const u8) void {
log("handleProxyResponse", .{});
var body = data;
if (this.body.items.len > 0) {
bun.handleOom(this.body.appendSlice(bun.default_allocator, data));
body = this.body.items;
}
// Check for HTTP 200 response from proxy
const is_first = this.body.items.len == 0;
const http_200 = "HTTP/1.1 200 ";
const http_200_alt = "HTTP/1.0 200 ";
if (is_first and body.len > http_200.len) {
if (!strings.hasPrefixComptime(body, http_200) and !strings.hasPrefixComptime(body, http_200_alt)) {
// Proxy connection failed
this.terminate(ErrorCode.proxy_connect_failed);
return;
}
}
// Parse the response to find the end of headers
const response = PicoHTTP.Response.parse(body, &this.headers_buf) catch |err| {
switch (err) {
error.Malformed_HTTP_Response => {
this.terminate(ErrorCode.invalid_response);
return;
},
error.ShortRead => {
if (this.body.items.len == 0) {
bun.handleOom(this.body.appendSlice(bun.default_allocator, data));
}
return;
},
}
};
// Proxy returned non-200 status
if (response.status_code != 200) {
if (response.status_code == 407) {
this.terminate(ErrorCode.proxy_authentication_required);
} else {
this.terminate(ErrorCode.proxy_connect_failed);
}
return;
}
// Proxy tunnel established
log("Proxy tunnel established", .{});
// Clear the body buffer for WebSocket handshake
this.body.clearRetainingCapacity();
const remain_buf = body[@as(usize, @intCast(response.bytes_read))..];
// Safely unwrap proxy state - it must exist if we're in proxy_handshake state
const p = if (this.proxy) |*proxy| proxy else {
this.terminate(ErrorCode.proxy_tunnel_failed);
return;
};
// For wss:// through proxy, we need to do TLS handshake inside the tunnel
if (p.isTargetHttps()) {
this.startProxyTLSHandshake(socket, remain_buf);
return;
}
// For ws:// through proxy, send the WebSocket upgrade request
this.state = .reading;
// Free the CONNECT request buffer
if (this.input_body_buf.len > 0) {
bun.default_allocator.free(this.input_body_buf);
}
// Use the WebSocket upgrade request from proxy state
this.input_body_buf = p.takeWebsocketRequestBuf();
// Send the WebSocket upgrade request
const wrote = socket.write(this.input_body_buf);
if (wrote < 0) {
this.terminate(ErrorCode.failed_to_write);
return;
}
this.to_send = this.input_body_buf[@as(usize, @intCast(wrote))..];
// If there's remaining data after the proxy response, process it
if (remain_buf.len > 0) {
this.handleData(socket, remain_buf);
}
}
/// Start TLS handshake inside the proxy tunnel for wss:// connections
fn startProxyTLSHandshake(this: *HTTPClient, socket: Socket, initial_data: []const u8) void {
log("startProxyTLSHandshake", .{});
// Safely unwrap proxy state - it must exist if we're called from handleProxyResponse
const p = if (this.proxy) |*proxy| proxy else {
this.terminate(ErrorCode.proxy_tunnel_failed);
return;
};
// Get certificate verification setting
const reject_unauthorized = if (this.outgoing_websocket) |ws| ws.rejectUnauthorized() else true;
// Create proxy tunnel with all parameters
const tunnel = WebSocketProxyTunnel.init(ssl, this, socket, p.getTargetHost(), reject_unauthorized) catch {
this.terminate(ErrorCode.proxy_tunnel_failed);
return;
};
// Use ssl_config if available, otherwise use defaults
const ssl_options: SSLConfig = if (this.ssl_config) |config| config.* else SSLConfig{
.reject_unauthorized = 0, // We verify manually
.request_cert = 1,
};
// Start TLS handshake
tunnel.start(ssl_options, initial_data) catch {
tunnel.deref();
this.terminate(ErrorCode.proxy_tunnel_failed);
return;
};
p.setTunnel(tunnel);
this.state = .proxy_tls_handshake;
}
/// Called by WebSocketProxyTunnel when TLS handshake completes successfully
pub fn onProxyTLSHandshakeComplete(this: *HTTPClient) void {
log("onProxyTLSHandshakeComplete", .{});
// TLS handshake done - send WebSocket upgrade request through tunnel
this.state = .reading;
// Free the CONNECT request buffer
if (this.input_body_buf.len > 0) {
bun.default_allocator.free(this.input_body_buf);
this.input_body_buf = &[_]u8{};
}
// Safely unwrap proxy state and send through the tunnel
const p = if (this.proxy) |*proxy| proxy else {
this.terminate(ErrorCode.proxy_tunnel_failed);
return;
};
// Take the WebSocket upgrade request from proxy state (transfers ownership)
const upgrade_request = p.takeWebsocketRequestBuf();
if (upgrade_request.len == 0) {
this.terminate(ErrorCode.failed_to_write);
return;
}
// Send through the tunnel (will be encrypted)
if (p.getTunnel()) |tunnel| {
_ = tunnel.write(upgrade_request) catch {
this.terminate(ErrorCode.failed_to_write);
return;
};
} else {
this.terminate(ErrorCode.proxy_tunnel_failed);
}
}
/// Called by WebSocketProxyTunnel with decrypted data from the TLS tunnel
pub fn handleDecryptedData(this: *HTTPClient, data: []const u8) void {
log("handleDecryptedData: {} bytes", .{data.len});
// Process as if it came directly from the socket
var body = data;
if (this.body.items.len > 0) {
bun.handleOom(this.body.appendSlice(bun.default_allocator, data));
@@ -533,6 +932,42 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
@memcpy(overflow, remain_buf);
}
// Check if we're using a proxy tunnel (wss:// through HTTP proxy)
if (this.proxy) |*p| {
if (p.getTunnel()) |tunnel| {
// wss:// through HTTP proxy: use tunnel mode
// For tunnel mode, the upgrade client STAYS ALIVE to forward socket data to the tunnel.
// The socket continues to call handleData on the upgrade client, which forwards to tunnel.
// The tunnel forwards decrypted data to the WebSocket client.
jsc.markBinding(@src());
if (!this.tcp.isClosed() and this.outgoing_websocket != null) {
this.tcp.timeout(0);
log("onDidConnect (tunnel mode)", .{});
// Take the outgoing_websocket reference but DON'T deref the upgrade client.
// We need to keep it alive to forward socket data to the tunnel.
// The upgrade client will be cleaned up when the socket closes.
const ws = bun.take(&this.outgoing_websocket).?;
// Create the WebSocket client with the tunnel
ws.didConnectWithTunnel(tunnel, overflow.ptr, overflow.len, if (deflate_result.enabled) &deflate_result.params else null);
// Switch state to connected - handleData will forward to tunnel
this.state = .done;
} else if (this.tcp.isClosed()) {
this.terminate(ErrorCode.cancel);
} else if (this.outgoing_websocket == null) {
this.tcp.close(.failure);
}
return;
}
}
// Normal (non-tunnel) mode - original code path
// Don't destroy custom SSL context yet - the socket still needs it!
// Save it before clearData() would destroy it, then transfer ownership to the WebSocket client.
const saved_custom_ssl_ctx = this.custom_ssl_ctx;
this.custom_ssl_ctx = null; // Prevent clearData from destroying it
this.clearData();
jsc.markBinding(@src());
if (!this.tcp.isClosed() and this.outgoing_websocket != null) {
@@ -544,11 +979,15 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
const ws = bun.take(&this.outgoing_websocket).?;
const socket = this.tcp;
// Normal mode: pass socket directly to WebSocket client
this.tcp.detach();
// Once again for the TCP socket.
defer this.deref();
ws.didConnect(socket.socket.get().?, overflow.ptr, overflow.len, if (deflate_result.enabled) &deflate_result.params else null);
if (socket.socket.get()) |native_socket| {
ws.didConnect(native_socket, overflow.ptr, overflow.len, if (deflate_result.enabled) &deflate_result.params else null, saved_custom_ssl_ctx);
} else {
this.terminate(ErrorCode.failed_to_connect);
}
} else if (this.tcp.isClosed()) {
this.terminate(ErrorCode.cancel);
} else if (this.outgoing_websocket == null) {
@@ -569,13 +1008,21 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
) void {
bun.assert(this.isSameSocket(socket));
// Forward to proxy tunnel if active
if (this.proxy) |*p| {
if (p.getTunnel()) |tunnel| {
tunnel.onWritable();
// In .done state (after WebSocket upgrade), just handle tunnel writes
if (this.state == .done) return;
}
}
if (this.to_send.len == 0)
return;
this.ref();
defer this.deref();
// Do not set MSG_MORE, see https://github.com/oven-sh/bun/issues/4010
const wrote = socket.write(this.to_send);
if (wrote < 0) {
this.terminate(ErrorCode.failed_to_write);
@@ -650,8 +1097,71 @@ const NonUTF8Headers = struct {
.values = values.?[0..len],
};
}
/// Convert NonUTF8Headers to bun.http.Headers
pub fn toHeaders(self: NonUTF8Headers, allocator: std.mem.Allocator) !Headers {
var headers = Headers{
.allocator = allocator,
};
errdefer headers.deinit();
for (self.names, self.values) |name, value| {
try headers.append(name.slice(), value.slice());
}
return headers;
}
};
/// Build HTTP CONNECT request for proxy tunneling
fn buildConnectRequest(
target_host: []const u8,
target_port: u16,
proxy_authorization: ?[]const u8,
proxy_headers: ?Headers,
) std.mem.Allocator.Error![]u8 {
const allocator = bun.default_allocator;
// Calculate size for the CONNECT request
var buf = std.array_list.Managed(u8).init(allocator);
errdefer buf.deinit();
const writer = buf.writer();
// CONNECT host:port HTTP/1.1\r\n
try writer.print("CONNECT {s}:{d} HTTP/1.1\r\n", .{ target_host, target_port });
// Host: host:port\r\n
try writer.print("Host: {s}:{d}\r\n", .{ target_host, target_port });
// Proxy-Connection: Keep-Alive\r\n
try writer.writeAll("Proxy-Connection: Keep-Alive\r\n");
// Proxy-Authorization if provided
if (proxy_authorization) |auth| {
try writer.print("Proxy-Authorization: {s}\r\n", .{auth});
}
// Custom proxy headers
if (proxy_headers) |hdrs| {
const slice = hdrs.entries.slice();
const names = slice.items(.name);
const values = slice.items(.value);
for (names, 0..) |name_ptr, idx| {
// Skip Proxy-Authorization if user provided one (we already added it)
const name = hdrs.asStr(name_ptr);
if (proxy_authorization != null and strings.eqlCaseInsensitiveASCII(name, "proxy-authorization", true)) {
continue;
}
try writer.print("{s}: {s}\r\n", .{ name, hdrs.asStr(values[idx]) });
}
}
// End of headers
try writer.writeAll("\r\n");
return buf.toOwnedSlice();
}
fn buildRequestBody(
vm: *jsc.VirtualMachine,
pathname: *const jsc.ZigString,
@@ -781,7 +1291,40 @@ fn buildRequestBody(
const log = Output.scoped(.WebSocketUpgradeClient, .visible);
/// Parse SSLConfig from a JavaScript TLS options object.
/// This function is exported for C++ to call from JSWebSocket.cpp.
/// Returns null if parsing fails (an exception will be set on globalThis).
/// The returned SSLConfig is heap-allocated and ownership is transferred to the caller.
pub fn parseSSLConfig(
globalThis: *jsc.JSGlobalObject,
tls_value: jsc.JSValue,
) callconv(.c) ?*SSLConfig {
const vm = globalThis.bunVM();
// Use SSLConfig.fromJS for clean and safe parsing
const config_opt = SSLConfig.fromJS(vm, globalThis, tls_value) catch {
// Exception is already set on globalThis
return null;
};
if (config_opt) |config| {
// Allocate on heap and return pointer (ownership transferred to caller)
const config_ptr = bun.handleOom(bun.default_allocator.create(SSLConfig));
config_ptr.* = config;
return config_ptr;
}
// No TLS options provided or all defaults, return null
return null;
}
comptime {
@export(&parseSSLConfig, .{ .name = "Bun__WebSocket__parseSSLConfig" });
}
const WebSocketDeflate = @import("./WebSocketDeflate.zig");
const WebSocketProxy = @import("./WebSocketProxy.zig");
const WebSocketProxyTunnel = @import("./WebSocketProxyTunnel.zig");
const std = @import("std");
const CppWebSocket = @import("./CppWebSocket.zig").CppWebSocket;
@@ -798,3 +1341,5 @@ const default_allocator = bun.default_allocator;
const jsc = bun.jsc;
const strings = bun.strings;
const uws = bun.uws;
const Headers = bun.http.Headers;
const SSLConfig = jsc.API.ServerConfig.SSLConfig;

View File

@@ -89,9 +89,62 @@ class BunWebSocket extends EventEmitter {
let headers;
let method = "GET";
let proxy;
let tlsOptions;
let agent;
// https://github.com/websockets/ws/blob/0d1b5e6c4acad16a6b1a1904426eb266a5ba2f72/lib/websocket.js#L741-L747
if ($isObject(options)) {
headers = options?.headers;
proxy = options?.proxy;
tlsOptions = options?.tls;
// Extract from agent if provided (like HttpsProxyAgent)
agent = options?.agent;
if ($isObject(agent)) {
// Get proxy from agent.proxy (can be URL object or string)
if (!proxy && agent.proxy) {
const agentProxy = agent.proxy?.href || agent.proxy;
// Get proxy headers from agent.proxyHeaders
if (agent.proxyHeaders) {
const proxyHeaders = $isCallable(agent.proxyHeaders) ? agent.proxyHeaders.$call(agent) : agent.proxyHeaders;
proxy = { url: agentProxy, headers: proxyHeaders };
} else {
proxy = agentProxy;
}
}
// Get TLS options from agent.connectOpts or agent.options
// Only extract specific TLS options we support (not ALPNProtocols, etc.)
if (!tlsOptions) {
const agentOpts = agent.connectOpts || agent.options;
if ($isObject(agentOpts)) {
const newTlsOptions = {};
let hasTlsOptions = false;
if (agentOpts.rejectUnauthorized !== undefined) {
newTlsOptions.rejectUnauthorized = agentOpts.rejectUnauthorized;
hasTlsOptions = true;
}
if (agentOpts.ca) {
newTlsOptions.ca = agentOpts.ca;
hasTlsOptions = true;
}
if (agentOpts.cert) {
newTlsOptions.cert = agentOpts.cert;
hasTlsOptions = true;
}
if (agentOpts.key) {
newTlsOptions.key = agentOpts.key;
hasTlsOptions = true;
}
if (agentOpts.passphrase) {
newTlsOptions.passphrase = agentOpts.passphrase;
hasTlsOptions = true;
}
if (hasTlsOptions) {
tlsOptions = newTlsOptions;
}
}
}
}
}
const finishRequest = options?.finishRequest;
@@ -131,7 +184,7 @@ class BunWebSocket extends EventEmitter {
end: () => {
if (!didCallEnd) {
didCallEnd = true;
this.#createWebSocket(url, protocols, headers, method);
this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions, agent);
}
},
write() {},
@@ -160,16 +213,27 @@ class BunWebSocket extends EventEmitter {
EventEmitter.$call(nodeHttpClientRequestSimulated);
finishRequest(nodeHttpClientRequestSimulated);
if (!didCallEnd) {
this.#createWebSocket(url, protocols, headers, method);
this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions, agent);
}
return;
}
this.#createWebSocket(url, protocols, headers, method);
this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions, agent);
}
#createWebSocket(url, protocols, headers, method) {
let ws = (this.#ws = new WebSocket(url, headers ? { headers, method, protocols } : protocols));
#createWebSocket(url, protocols, headers, method, proxy, tls, agent) {
let wsOptions;
if (headers || proxy || tls || agent) {
wsOptions = { protocols };
if (headers) wsOptions.headers = headers;
if (method) wsOptions.method = method;
if (proxy) wsOptions.proxy = proxy;
if (tls) wsOptions.tls = tls;
if (agent) wsOptions.agent = agent;
} else {
wsOptions = protocols;
}
let ws = (this.#ws = new WebSocket(url, wsOptions));
ws.binaryType = "nodebuffer";
return ws;

View File

@@ -360,13 +360,9 @@ pub fn createInstance(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFra
return .zero;
}
// we always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match
const original_reject_unauthorized = tls_config.reject_unauthorized;
tls_config.reject_unauthorized = 0;
tls_config.request_cert = 1;
// We always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match.
// We create it right here so we can throw errors early.
const context_options = tls_config.asUSockets();
const context_options = tls_config.asUSocketsForClientVerification();
var err: uws.create_bun_socket_error_t = .none;
tls_ctx = uws.SocketContext.createSSLContext(vm.uwsLoop(), @sizeOf(*@This()), context_options, &err) orelse {
if (err != .none) {
@@ -375,9 +371,6 @@ pub fn createInstance(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFra
return globalObject.throwValue(err.toJS(globalObject));
}
};
// restore the original reject_unauthorized
tls_config.reject_unauthorized = original_reject_unauthorized;
if (err != .none) {
tls_config.deinit();
if (tls_ctx) |ctx| {

View File

@@ -618,12 +618,9 @@ pub fn call(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS
return .zero;
}
// we always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match
const original_reject_unauthorized = tls_config.reject_unauthorized;
tls_config.reject_unauthorized = 0;
tls_config.request_cert = 1;
// We always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match.
// We create it right here so we can throw errors early.
const context_options = tls_config.asUSockets();
const context_options = tls_config.asUSocketsForClientVerification();
var err: uws.create_bun_socket_error_t = .none;
tls_ctx = uws.SocketContext.createSSLContext(vm.uwsLoop(), @sizeOf(*PostgresSQLConnection), context_options, &err) orelse {
if (err != .none) {
@@ -632,8 +629,6 @@ pub fn call(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS
return globalObject.throwValue(err.toJS(globalObject));
}
};
// restore the original reject_unauthorized
tls_config.reject_unauthorized = original_reject_unauthorized;
if (err != .none) {
tls_config.deinit();
if (tls_ctx) |ctx| {

View File

@@ -47,7 +47,7 @@
"happy-dom": "17.0.3",
"hono": "4.7.2",
"http2-wrapper": "2.2.1",
"https-proxy-agent": "7.0.5",
"https-proxy-agent": "7.0.6",
"iconv-lite": "0.6.3",
"immutable": "5.1.3",
"isbot": "5.1.13",
@@ -860,7 +860,7 @@
"acorn-walk": ["acorn-walk@8.3.3", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw=="],
"agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="],
"agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
@@ -1524,7 +1524,7 @@
"http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="],
"https-proxy-agent": ["https-proxy-agent@7.0.5", "", { "dependencies": { "agent-base": "^7.0.2", "debug": "4" } }, "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
@@ -2834,8 +2834,6 @@
"@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.4.15", "", {}, "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="],
"@mapbox/node-pre-gyp/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@mapbox/node-pre-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
"@mapbox/node-pre-gyp/tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
@@ -2844,6 +2842,8 @@
"@nestjs/core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@npmcli/agent/agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="],
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.4", "", { "dependencies": { "agent-base": "^7.0.2", "debug": "4" } }, "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg=="],
"@npmcli/agent/lru-cache": ["lru-cache@10.2.2", "", {}, "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="],
@@ -2864,7 +2864,7 @@
"@remix-run/react/@remix-run/server-runtime": ["@remix-run/server-runtime@2.10.3", "", { "dependencies": { "@remix-run/router": "1.18.0", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", "set-cookie-parser": "^2.4.8", "source-map": "^0.7.3", "turbo-stream": "2.2.0" }, "peerDependencies": { "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-vUl5jONUI6Lj0ICg9FSRFhoPzQdZ/7dpT1m7ID13DF5BEeF3t/9uCJS61XXWgQ/JEu7YRiwvZiwSRTrgM7zeWw=="],
"@remix-run/react/react": ["react@file:../node_modules/react", {}],
"@remix-run/react/react": ["react@file:../node_modules/react", { "dependencies": { "loose-envify": "^1.1.0" } }],
"@remix-run/serve/@remix-run/node": ["@remix-run/node@2.10.3", "", { "dependencies": { "@remix-run/server-runtime": "2.10.3", "@remix-run/web-fetch": "^4.4.2", "@web3-storage/multipart-parser": "^1.0.0", "cookie-signature": "^1.1.0", "source-map-support": "^0.5.21", "stream-slice": "^0.1.2", "undici": "^6.11.1" }, "peerDependencies": { "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-LBqsgADJKW7tYdJZZi2wu20gfMm6UcOXbvb5U70P2jCNxjJvuIw1gXVvNXRJKAdxPKLonjm8cSpfoI6HeQKEDg=="],
@@ -2886,7 +2886,7 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@testing-library/react/react": ["react@file:../node_modules/react", {}],
"@testing-library/react/react": ["react@file:../node_modules/react", { "dependencies": { "loose-envify": "^1.1.0" } }],
"@types/eslint/@types/estree": ["@types/estree@1.0.5", "", {}, "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw=="],
@@ -2956,8 +2956,6 @@
"acorn-walk/acorn": ["acorn@8.12.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw=="],
"agent-base/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-keywords/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
@@ -3122,9 +3120,11 @@
"header-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"http-proxy-agent/agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="],
"http-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
"https-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
"https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -3136,8 +3136,6 @@
"jsdom/form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="],
"jsdom/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"jsdom/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
@@ -3196,8 +3194,12 @@
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"pac-proxy-agent/agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="],
"pac-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.5", "", { "dependencies": { "agent-base": "^7.0.2", "debug": "4" } }, "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw=="],
"pac-proxy-agent/socks-proxy-agent": ["socks-proxy-agent@8.0.4", "", { "dependencies": { "agent-base": "^7.1.1", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw=="],
"param-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -3232,8 +3234,12 @@
"proxy/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"proxy-agent/agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="],
"proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.5", "", { "dependencies": { "agent-base": "^7.0.2", "debug": "4" } }, "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw=="],
"proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"proxy-agent/socks-proxy-agent": ["socks-proxy-agent@8.0.3", "", { "dependencies": { "agent-base": "^7.1.1", "debug": "^4.3.4", "socks": "^2.7.1" } }, "sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A=="],
@@ -3248,11 +3254,11 @@
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"react-dom/react": ["react@file:../node_modules/react", {}],
"react-dom/react": ["react@file:../node_modules/react", { "dependencies": { "loose-envify": "^1.1.0" } }],
"react-router/react": ["react@file:../node_modules/react", {}],
"react-router/react": ["react@file:../node_modules/react", { "dependencies": { "loose-envify": "^1.1.0" } }],
"react-router-dom/react": ["react@file:../node_modules/react", {}],
"react-router-dom/react": ["react@file:../node_modules/react", { "dependencies": { "loose-envify": "^1.1.0" } }],
"readable-web-to-node-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
@@ -3444,6 +3450,8 @@
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@azure/core-rest-pipeline/https-proxy-agent/agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="],
"@azure/core-rest-pipeline/https-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
"@cypress/request/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
@@ -3458,10 +3466,6 @@
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"@mapbox/node-pre-gyp/https-proxy-agent/agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
"@mapbox/node-pre-gyp/https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"@mapbox/node-pre-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
"@mapbox/node-pre-gyp/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
@@ -3472,6 +3476,8 @@
"@mapbox/node-pre-gyp/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"@npmcli/agent/agent-base/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
"@npmcli/agent/https-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
"@npmcli/agent/socks-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
@@ -3604,8 +3610,6 @@
"@vitest/snapshot/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.4.15", "", {}, "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="],
"agent-base/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -3756,8 +3760,6 @@
"http-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"https-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"jest-diff/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jest-diff/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
@@ -3766,10 +3768,6 @@
"jest-worker/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"jsdom/https-proxy-agent/agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
"jsdom/https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"jsonwebtoken/jws/jwa": ["jwa@1.4.1", "", { "dependencies": { "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA=="],
"mongodb-connection-string-url/whatwg-url/tr46": ["tr46@3.0.0", "", { "dependencies": { "punycode": "^2.1.1" } }, "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA=="],
@@ -3782,8 +3780,16 @@
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"pac-proxy-agent/agent-base/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
"pac-proxy-agent/https-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
"peek-stream/duplexify/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"proxy-agent/agent-base/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
"proxy-agent/https-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
"proxy-agent/socks-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="],
"pumpify/duplexify/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
@@ -4022,6 +4028,8 @@
"@inquirer/core/wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"@npmcli/agent/agent-base/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"@npmcli/agent/https-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"@npmcli/agent/socks-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
@@ -4092,12 +4100,20 @@
"msw/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"pac-proxy-agent/agent-base/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"pac-proxy-agent/https-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"peek-stream/duplexify/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"peek-stream/duplexify/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"peek-stream/duplexify/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"proxy-agent/agent-base/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"proxy-agent/https-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"proxy-agent/socks-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"pumpify/duplexify/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],

View File

@@ -0,0 +1,20 @@
# Squid configuration for WebSocket proxy testing
# Minimal configuration allowing CONNECT to any port
# Allow all ports for CONNECT (needed for WebSocket on non-standard ports)
acl SSL_ports port 1-65535
acl Safe_ports port 1-65535
acl CONNECT method CONNECT
# Access control - allow everything for testing
http_access allow all
# Listen on port 3128
http_port 3128
# Disable caching
cache deny all
# Logging to stdout/stderr for Docker
access_log none
cache_log /dev/stderr

View File

@@ -206,6 +206,25 @@ services:
published: 0 # Dynamic port
protocol: tcp
# Squid proxy for WebSocket proxy testing
squid:
image: ubuntu/squid:5.2-22.04_beta
volumes:
- ./config/squid.conf:/etc/squid/squid.conf:ro
ports:
- target: 3128
published: 0
protocol: tcp
# Use extra_hosts to allow connections back to host services via bridge network
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD-SHELL", "pgrep squid > /dev/null"]
interval: 1h
timeout: 5s
retries: 30
start_period: 10s
volumes:
redis-unix:
redis-data:

View File

@@ -1,7 +1,7 @@
import { spawn } from "bun";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import * as net from "net";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -16,7 +16,8 @@ export type ServiceName =
| "redis_plain"
| "redis_unified"
| "minio"
| "autobahn";
| "autobahn"
| "squid";
export interface ServiceInfo {
host: string;
@@ -41,14 +42,14 @@ class DockerComposeHelper {
private runningServices: Set<ServiceName> = new Set();
constructor(options: DockerComposeOptions = {}) {
this.projectName = options.projectName ||
this.projectName =
options.projectName ||
process.env.BUN_DOCKER_PROJECT_NAME ||
process.env.COMPOSE_PROJECT_NAME ||
"bun-test-services"; // Default project name for all test services
"bun-test-services"; // Default project name for all test services
this.composeFile = options.composeFile ||
process.env.BUN_DOCKER_COMPOSE_FILE ||
join(__dirname, "docker-compose.yml");
this.composeFile =
options.composeFile || process.env.BUN_DOCKER_COMPOSE_FILE || join(__dirname, "docker-compose.yml");
// Verify the compose file exists
const fs = require("fs");
@@ -70,10 +71,7 @@ class DockerComposeHelper {
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
proc.stdout.text(),
proc.stderr.text(),
]);
const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
const exitCode = await proc.exited;
@@ -151,12 +149,12 @@ class DockerComposeHelper {
try {
const socket = new net.Socket();
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => {
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once('error', reject);
socket.connect(port, '127.0.0.1');
socket.once("error", reject);
socket.connect(port, "127.0.0.1");
});
return;
} catch {
@@ -255,6 +253,10 @@ class DockerComposeHelper {
info.ports[9002] = await this.port(service, 9002);
// Docker compose --wait should handle readiness
break;
case "squid":
info.ports[3128] = await this.port(service, 3128);
break;
}
return info;
@@ -322,6 +324,12 @@ class DockerComposeHelper {
case "autobahn":
env.AUTOBAHN_URL = `ws://${info.host}:${info.ports[9002]}`;
break;
case "squid":
env.HTTP_PROXY = `http://${info.host}:${info.ports[3128]}`;
env.HTTPS_PROXY = `http://${info.host}:${info.ports[3128]}`;
env.PROXY_URL = `http://${info.host}:${info.ports[3128]}`;
break;
}
return env;
@@ -449,7 +457,7 @@ export async function prepareImages(): Promise<void> {
// Higher-level wrappers for tests
export async function withPostgres(
opts: { variant?: "plain" | "tls" | "auth" },
fn: (info: ServiceInfo & { url: string }) => Promise<void>
fn: (info: ServiceInfo & { url: string }) => Promise<void>,
): Promise<void> {
const variant = opts.variant || "plain";
const serviceName = `postgres_${variant}` as ServiceName;
@@ -467,7 +475,7 @@ export async function withPostgres(
export async function withMySQL(
opts: { variant?: "plain" | "native_password" | "tls" },
fn: (info: ServiceInfo & { url: string }) => Promise<void>
fn: (info: ServiceInfo & { url: string }) => Promise<void>,
): Promise<void> {
const variant = opts.variant || "plain";
const serviceName = `mysql_${variant}` as ServiceName;
@@ -485,7 +493,7 @@ export async function withMySQL(
export async function withRedis(
opts: { variant?: "plain" | "unified" },
fn: (info: ServiceInfo & { url: string; tlsUrl?: string }) => Promise<void>
fn: (info: ServiceInfo & { url: string; tlsUrl?: string }) => Promise<void>,
): Promise<void> {
const variant = opts.variant || "plain";
const serviceName = `redis_${variant}` as ServiceName;
@@ -502,7 +510,7 @@ export async function withRedis(
}
export async function withMinio(
fn: (info: ServiceInfo & { endpoint: string; accessKeyId: string; secretAccessKey: string }) => Promise<void>
fn: (info: ServiceInfo & { endpoint: string; accessKeyId: string; secretAccessKey: string }) => Promise<void>,
): Promise<void> {
const info = await ensure("minio");
@@ -518,9 +526,7 @@ export async function withMinio(
}
}
export async function withAutobahn(
fn: (info: ServiceInfo & { url: string }) => Promise<void>
): Promise<void> {
export async function withAutobahn(fn: (info: ServiceInfo & { url: string }) => Promise<void>): Promise<void> {
const info = await ensure("autobahn");
try {
@@ -531,4 +537,17 @@ export async function withAutobahn(
} finally {
// Services persist - no teardown
}
}
}
export async function withSquid(fn: (info: ServiceInfo & { proxyUrl: string }) => Promise<void>): Promise<void> {
const info = await ensure("squid");
try {
await fn({
...info,
proxyUrl: `http://${info.host}:${info.ports[3128]}`,
});
} finally {
// Services persist - no teardown
}
}

View File

@@ -0,0 +1,600 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { gc, tls as tlsCerts } from "harness";
import type { HttpsProxyAgent as HttpsProxyAgentType } from "https-proxy-agent";
import net from "net";
import tls from "tls";
import WebSocket from "ws";
import { createConnectProxy, createTLSConnectProxy, startProxy } from "../../web/websocket/proxy-test-utils";
// Use dynamic require to avoid linter removing the import
const { HttpsProxyAgent } = require("https-proxy-agent") as {
HttpsProxyAgent: typeof HttpsProxyAgentType;
};
// HTTP CONNECT proxy server for WebSocket tunneling
let proxy: net.Server;
let authProxy: net.Server;
let httpsProxy: tls.Server;
let wsServer: ReturnType<typeof Bun.serve>;
let wssServer: ReturnType<typeof Bun.serve>;
let proxyPort: number;
let authProxyPort: number;
let httpsProxyPort: number;
let wsPort: number;
let wssPort: number;
beforeAll(async () => {
// Create HTTP CONNECT proxy
proxy = createConnectProxy();
proxyPort = await startProxy(proxy);
// Create HTTP CONNECT proxy with auth
authProxy = createConnectProxy({ requireAuth: true });
authProxyPort = await startProxy(authProxy);
// Create HTTPS CONNECT proxy
httpsProxy = createTLSConnectProxy();
httpsProxyPort = await startProxy(httpsProxy);
// Create WebSocket echo server
wsServer = Bun.serve({
port: 0,
fetch(req, server) {
if (server.upgrade(req)) {
return;
}
return new Response("Expected WebSocket", { status: 400 });
},
websocket: {
message(ws, message) {
// Echo back
ws.send(message);
},
open(ws) {
ws.send("connected");
},
},
});
wsPort = wsServer.port;
// Create secure WebSocket echo server (wss://)
wssServer = Bun.serve({
port: 0,
tls: {
key: tlsCerts.key,
cert: tlsCerts.cert,
},
fetch(req, server) {
if (server.upgrade(req)) {
return;
}
return new Response("Expected WebSocket", { status: 400 });
},
websocket: {
message(ws, message) {
// Echo back
ws.send(message);
},
open(ws) {
ws.send("connected");
},
},
});
wssPort = wssServer.port;
});
afterAll(() => {
proxy?.close();
authProxy?.close();
httpsProxy?.close();
wsServer?.stop(true);
wssServer?.stop(true);
});
describe("ws package proxy API", () => {
test("accepts proxy option as string (HTTP proxy)", () => {
const ws = new WebSocket("ws://example.com", {
proxy: `http://127.0.0.1:${proxyPort}`,
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy option as string (HTTPS proxy)", () => {
const ws = new WebSocket("ws://example.com", {
proxy: `https://127.0.0.1:${httpsProxyPort}`,
tls: { rejectUnauthorized: false },
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy option with object containing url", () => {
const ws = new WebSocket("ws://example.com", {
proxy: { url: `http://127.0.0.1:${proxyPort}` },
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy URL with credentials", () => {
const ws = new WebSocket("ws://example.com", {
proxy: `http://user:pass@127.0.0.1:${authProxyPort}`,
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("can combine proxy with headers and protocols", () => {
const ws = new WebSocket("ws://example.com", ["graphql-ws"], {
proxy: `http://127.0.0.1:${proxyPort}`,
headers: { Authorization: "Bearer token" },
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("rejects invalid proxy URL", () => {
expect(() => {
new WebSocket("ws://example.com", {
proxy: "not-a-valid-url",
});
}).toThrow(SyntaxError);
});
});
describe("ws package through HTTP CONNECT proxy", () => {
test("ws:// through HTTP proxy", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://127.0.0.1:${proxyPort}`,
});
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello from ws client");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from ws client");
gc();
});
test("ws:// through HTTP proxy with auth", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://proxy_user:proxy_pass@127.0.0.1:${authProxyPort}`,
});
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello with auth via ws");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello with auth via ws");
gc();
});
test("proxy auth failure returns error", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let sawError = false;
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://127.0.0.1:${authProxyPort}`, // No auth provided
});
ws.on("open", () => {
ws.close();
reject(new Error("Expected proxy auth failure, but connection opened"));
});
ws.on("error", () => {
sawError = true;
ws.close();
});
ws.on("close", () => {
if (sawError) {
resolve();
} else {
reject(new Error("Expected proxy auth failure (error event), got clean close instead"));
}
});
await promise;
gc();
});
test("proxy wrong credentials returns error", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let sawError = false;
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://wrong_user:wrong_pass@127.0.0.1:${authProxyPort}`,
});
ws.on("open", () => {
ws.close();
reject(new Error("Expected proxy auth failure, but connection opened"));
});
ws.on("error", () => {
sawError = true;
ws.close();
});
ws.on("close", () => {
if (sawError) {
resolve();
} else {
reject(new Error("Expected proxy auth failure (error event), got clean close instead"));
}
});
await promise;
gc();
});
});
describe("ws package wss:// through HTTP proxy (TLS tunnel)", () => {
test("wss:// through HTTP proxy", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, {
proxy: `http://127.0.0.1:${proxyPort}`,
tls: {
rejectUnauthorized: false, // Trust self-signed cert
},
});
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello via tls tunnel from ws");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello via tls tunnel from ws");
gc();
});
});
describe("ws package through HTTPS proxy (TLS proxy)", () => {
test("ws:// through HTTPS proxy with CA certificate", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `https://127.0.0.1:${httpsProxyPort}`,
tls: {
ca: tlsCerts.cert, // Trust self-signed proxy cert
},
});
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello via https proxy from ws");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello via https proxy from ws");
gc();
});
test("ws:// through HTTPS proxy with rejectUnauthorized: false", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `https://127.0.0.1:${httpsProxyPort}`,
tls: {
rejectUnauthorized: false, // Skip TLS verification for proxy
},
});
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello via https proxy no verify from ws");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello via https proxy no verify from ws");
gc();
});
test("ws:// through HTTPS proxy fails without CA certificate", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let sawError = false;
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `https://127.0.0.1:${httpsProxyPort}`,
// No CA certificate - should fail (self-signed cert not trusted)
});
ws.on("open", () => {
ws.close();
reject(new Error("Expected TLS verification failure, but connection opened"));
});
ws.on("error", () => {
sawError = true;
ws.close();
});
ws.on("close", () => {
if (sawError) {
resolve();
} else {
reject(new Error("Expected TLS verification failure (error event), got clean close instead"));
}
});
await promise;
gc();
});
});
describe("ws package with HttpsProxyAgent", () => {
test("ws:// through HttpsProxyAgent", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`);
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent });
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello from ws via HttpsProxyAgent");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from ws via HttpsProxyAgent");
gc();
});
test("wss:// through HttpsProxyAgent with rejectUnauthorized", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`, {
rejectUnauthorized: false,
});
const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, { agent });
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello from wss via HttpsProxyAgent");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from wss via HttpsProxyAgent");
gc();
});
test("HttpsProxyAgent with authentication", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const agent = new HttpsProxyAgent(`http://proxy_user:proxy_pass@127.0.0.1:${authProxyPort}`);
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent });
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello from ws with auth via HttpsProxyAgent");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from ws with auth via HttpsProxyAgent");
gc();
});
test("HttpsProxyAgent with agent.proxy as URL object", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
// HttpsProxyAgent stores the proxy URL as a URL object in agent.proxy
const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`);
// Verify the agent has the proxy property as a URL object
expect(agent.proxy).toBeDefined();
expect(typeof agent.proxy).toBe("object");
expect(agent.proxy.href).toContain(`127.0.0.1:${proxyPort}`);
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent });
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello via agent with URL object");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello via agent with URL object");
gc();
});
test("explicit proxy option takes precedence over agent", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
// Create agent pointing to wrong port (that doesn't exist)
const agent = new HttpsProxyAgent(`http://127.0.0.1:1`);
// But use explicit proxy option with correct port
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
agent,
proxy: `http://127.0.0.1:${proxyPort}`, // This should take precedence
});
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("explicit proxy wins");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("explicit proxy wins");
gc();
});
});

View File

@@ -0,0 +1,197 @@
/**
* Shared utilities for WebSocket proxy tests.
* Used by both websocket-proxy.test.ts and ws-proxy.test.ts
*/
import { tls as tlsCerts } from "harness";
import net from "net";
import tls from "tls";
export interface ConnectProxyOptions {
requireAuth?: boolean;
}
/**
* Create an HTTP CONNECT proxy server using Node's net module.
* This proxy handles the CONNECT method to establish tunnels for WebSocket connections.
*/
export function createConnectProxy(options: ConnectProxyOptions = {}): net.Server {
return net.createServer(clientSocket => {
let buffer = Buffer.alloc(0);
let tunnelEstablished = false;
let targetSocket: net.Socket | null = null;
clientSocket.on("data", data => {
// If tunnel is already established, forward data directly
if (tunnelEstablished && targetSocket) {
targetSocket.write(data);
return;
}
buffer = Buffer.concat([buffer, data]);
const bufferStr = buffer.toString();
// Check if we have complete headers
const headerEnd = bufferStr.indexOf("\r\n\r\n");
if (headerEnd === -1) return;
const headerPart = bufferStr.substring(0, headerEnd);
const lines = headerPart.split("\r\n");
const requestLine = lines[0];
const headers: Record<string, string> = {};
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line === "") break;
const colonIdx = line.indexOf(": ");
if (colonIdx > 0) {
headers[line.substring(0, colonIdx).toLowerCase()] = line.substring(colonIdx + 2);
}
}
// Check for CONNECT method
const match = requestLine.match(/^CONNECT\s+([^:]+):(\d+)\s+HTTP/);
if (!match) {
clientSocket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
clientSocket.end();
return;
}
const [, targetHost, targetPort] = match;
// Check auth if required
if (options.requireAuth) {
const authHeader = headers["proxy-authorization"];
if (!authHeader) {
clientSocket.write("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n");
clientSocket.end();
return;
}
const auth = Buffer.from(authHeader.replace("Basic ", "").trim(), "base64").toString("utf8");
if (auth !== "proxy_user:proxy_pass") {
clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
clientSocket.end();
return;
}
}
// Get any data after the headers (shouldn't be any for CONNECT)
const remainingData = buffer.subarray(headerEnd + 4);
// Connect to target
targetSocket = net.connect(parseInt(targetPort), targetHost, () => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
tunnelEstablished = true;
// Forward any remaining data
if (remainingData.length > 0) {
targetSocket!.write(remainingData);
}
// Set up bidirectional piping
targetSocket!.on("data", chunk => {
clientSocket.write(chunk);
});
});
targetSocket.on("error", () => {
if (!tunnelEstablished) {
clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
}
clientSocket.end();
});
targetSocket.on("close", () => clientSocket.destroy());
clientSocket.on("close", () => targetSocket?.destroy());
});
clientSocket.on("error", () => {
targetSocket?.destroy();
});
});
}
/**
* Create an HTTPS CONNECT proxy server using Node's tls module.
* This proxy handles TLS-encrypted CONNECT tunnels.
*/
export function createTLSConnectProxy(): tls.Server {
return tls.createServer(
{
key: tlsCerts.key,
cert: tlsCerts.cert,
},
clientSocket => {
let buffer = Buffer.alloc(0);
let tunnelEstablished = false;
let targetSocket: net.Socket | null = null;
clientSocket.on("data", data => {
if (tunnelEstablished && targetSocket) {
targetSocket.write(data);
return;
}
buffer = Buffer.concat([buffer, data]);
const bufferStr = buffer.toString();
const headerEnd = bufferStr.indexOf("\r\n\r\n");
if (headerEnd === -1) return;
const headerPart = bufferStr.substring(0, headerEnd);
const lines = headerPart.split("\r\n");
const requestLine = lines[0];
const match = requestLine.match(/^CONNECT\s+([^:]+):(\d+)\s+HTTP/);
if (!match) {
clientSocket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
clientSocket.end();
return;
}
const [, targetHost, targetPort] = match;
const remainingData = buffer.subarray(headerEnd + 4);
targetSocket = net.connect(parseInt(targetPort), targetHost, () => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
tunnelEstablished = true;
if (remainingData.length > 0) {
targetSocket!.write(remainingData);
}
targetSocket!.on("data", chunk => {
clientSocket.write(chunk);
});
});
targetSocket.on("error", () => {
if (!tunnelEstablished) {
clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
}
clientSocket.end();
});
targetSocket.on("close", () => clientSocket.destroy());
clientSocket.on("close", () => targetSocket?.destroy());
});
clientSocket.on("error", () => {
targetSocket?.destroy();
});
},
);
}
/**
* Helper to start a proxy server and get its port.
*/
export async function startProxy(server: net.Server | tls.Server): Promise<number> {
return new Promise<number>(resolve => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as net.AddressInfo;
resolve(addr.port);
});
});
}

View File

@@ -0,0 +1,911 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import * as harness from "harness";
import { tls as tlsCerts } from "harness";
import type { HttpsProxyAgent as HttpsProxyAgentType } from "https-proxy-agent";
import net from "net";
import tls from "tls";
import { createConnectProxy, createTLSConnectProxy, startProxy } from "./proxy-test-utils";
// Use dynamic require to avoid linter removing the import
const { HttpsProxyAgent } = require("https-proxy-agent") as {
HttpsProxyAgent: typeof HttpsProxyAgentType;
};
// Use docker-compose infrastructure for squid proxy
const gc = harness.gc;
const isDockerEnabled = harness.isDockerEnabled;
// HTTP CONNECT proxy server for WebSocket tunneling
let proxy: net.Server;
let authProxy: net.Server;
let wsServer: ReturnType<typeof Bun.serve>;
let wssServer: ReturnType<typeof Bun.serve>;
let proxyPort: number;
let authProxyPort: number;
let wsPort: number;
let wssPort: number;
beforeAll(async () => {
// Create HTTP CONNECT proxy
proxy = createConnectProxy();
proxyPort = await startProxy(proxy);
// Create HTTP CONNECT proxy with auth
authProxy = createConnectProxy({ requireAuth: true });
authProxyPort = await startProxy(authProxy);
// Create WebSocket echo server
wsServer = Bun.serve({
port: 0,
fetch(req, server) {
if (server.upgrade(req)) {
return;
}
return new Response("Expected WebSocket", { status: 400 });
},
websocket: {
message(ws, message) {
// Echo back
ws.send(message);
},
open(ws) {
ws.send("connected");
},
},
});
wsPort = wsServer.port;
// Create secure WebSocket echo server (wss://)
wssServer = Bun.serve({
port: 0,
tls: {
key: tlsCerts.key,
cert: tlsCerts.cert,
},
fetch(req, server) {
if (server.upgrade(req)) {
return;
}
return new Response("Expected WebSocket", { status: 400 });
},
websocket: {
message(ws, message) {
// Echo back
ws.send(message);
},
open(ws) {
ws.send("connected");
},
},
});
wssPort = wssServer.port;
});
afterAll(() => {
proxy?.close();
authProxy?.close();
wsServer?.stop(true);
wssServer?.stop(true);
});
describe("WebSocket proxy API", () => {
test("accepts proxy option as string (HTTP proxy)", () => {
const ws = new WebSocket("ws://example.com", {
proxy: `http://127.0.0.1:${proxyPort}`,
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy option as string (HTTPS proxy)", () => {
// Note: This test just checks the constructor accepts the option.
// The actual connection would fail without proper TLS setup for the proxy.
const ws = new WebSocket("ws://example.com", {
proxy: `https://127.0.0.1:${proxyPort}`,
tls: { rejectUnauthorized: false },
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts HTTPS proxy with wss:// target", () => {
// Note: This test just checks the constructor accepts the option.
const ws = new WebSocket("wss://example.com", {
proxy: `https://127.0.0.1:${proxyPort}`,
tls: { rejectUnauthorized: false },
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy option as object with url", () => {
const ws = new WebSocket("ws://example.com", {
proxy: { url: `http://127.0.0.1:${proxyPort}` },
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy option with headers", () => {
const ws = new WebSocket("ws://example.com", {
proxy: {
url: `http://127.0.0.1:${proxyPort}`,
headers: { "X-Custom-Header": "test-value" },
},
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy option with Headers class instance", () => {
const headers = new Headers({ "X-Custom-Header": "test-value" });
const ws = new WebSocket("ws://example.com", {
proxy: {
url: `http://127.0.0.1:${proxyPort}`,
headers: headers,
},
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy URL with credentials", () => {
const ws = new WebSocket("ws://example.com", {
proxy: `http://user:pass@127.0.0.1:${authProxyPort}`,
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("can combine proxy with other options", () => {
const ws = new WebSocket("ws://example.com", {
proxy: `http://127.0.0.1:${proxyPort}`,
headers: { Authorization: "Bearer token" },
protocols: ["graphql-ws"],
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("rejects invalid proxy URL", () => {
expect(() => {
new WebSocket("ws://example.com", {
proxy: "not-a-valid-url",
});
}).toThrow(SyntaxError);
});
});
describe("WebSocket through HTTP CONNECT proxy", () => {
test("ws:// through HTTP proxy", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://127.0.0.1:${proxyPort}`,
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello from client");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from client");
gc();
});
test("ws:// through HTTP proxy with auth", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://proxy_user:proxy_pass@127.0.0.1:${authProxyPort}`,
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello with auth");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello with auth");
gc();
});
test("ws:// through proxy with custom headers", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: {
url: `http://127.0.0.1:${proxyPort}`,
headers: { "X-Custom-Proxy-Header": "test-value" },
},
});
ws.onopen = () => {
ws.close();
resolve();
};
ws.onerror = event => {
reject(event);
};
await promise;
gc();
});
test("ws:// through proxy with Headers class instance", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
const headers = new Headers({ "X-Custom-Proxy-Header": "test-value-from-headers-class" });
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: {
url: `http://127.0.0.1:${proxyPort}`,
headers: headers,
},
});
ws.onopen = () => {
ws.close();
resolve();
};
ws.onerror = event => {
reject(event);
};
await promise;
gc();
});
test("proxy auth failure returns error", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let sawError = false;
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://127.0.0.1:${authProxyPort}`, // No auth provided
});
ws.onopen = () => {
ws.close();
reject(new Error("Expected proxy auth failure, but connection opened"));
};
ws.onerror = () => {
sawError = true;
ws.close();
};
ws.onclose = () => {
if (sawError) {
resolve();
} else {
reject(new Error("Expected proxy auth failure (error event), got clean close instead"));
}
};
await promise;
gc();
});
test("proxy wrong credentials returns error", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let sawError = false;
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://wrong_user:wrong_pass@127.0.0.1:${authProxyPort}`,
});
ws.onopen = () => {
ws.close();
reject(new Error("Expected proxy auth failure, but connection opened"));
};
ws.onerror = () => {
sawError = true;
ws.close();
};
ws.onclose = () => {
if (sawError) {
resolve();
} else {
reject(new Error("Expected proxy auth failure (error event), got clean close instead"));
}
};
await promise;
gc();
});
});
describe("WebSocket wss:// through HTTP proxy (TLS tunnel)", () => {
// This tests the TLS tunnel: wss:// target through HTTP proxy
// The outer connection is plain TCP to the HTTP proxy, then TLS is
// negotiated inside the tunnel to the wss:// target server.
test("wss:// through HTTP proxy", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
// Use local wss:// server with self-signed cert
const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, {
proxy: `http://127.0.0.1:${proxyPort}`,
tls: {
// Trust the self-signed certificate used by the wss:// server
rejectUnauthorized: false,
},
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello via tls tunnel");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello via tls tunnel");
gc();
});
});
describe("WebSocket through HTTPS proxy (TLS proxy)", () => {
// These tests verify WebSocket connections through HTTPS (TLS) proxy servers
let httpsProxy: tls.Server;
let httpsProxyPort: number;
beforeAll(async () => {
// Create HTTPS CONNECT proxy
httpsProxy = createTLSConnectProxy();
httpsProxyPort = await startProxy(httpsProxy);
});
afterAll(() => {
httpsProxy?.close();
});
test("ws:// through HTTPS proxy with CA certificate", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `https://127.0.0.1:${httpsProxyPort}`,
tls: {
// Trust the self-signed certificate used by the proxy
ca: tlsCerts.cert,
},
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello via https proxy");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello via https proxy");
gc();
});
test("ws:// through HTTPS proxy fails without CA certificate", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let sawError = false;
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `https://127.0.0.1:${httpsProxyPort}`,
// No CA certificate - should fail (self-signed cert not trusted)
});
ws.onopen = () => {
ws.close();
reject(new Error("Expected TLS verification failure, but connection opened"));
};
ws.onerror = () => {
sawError = true;
ws.close();
};
ws.onclose = () => {
if (sawError) {
resolve();
} else {
reject(new Error("Expected TLS verification failure (error event), got clean close instead"));
}
};
await promise;
gc();
});
test("ws:// through HTTPS proxy with rejectUnauthorized: false", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `https://127.0.0.1:${httpsProxyPort}`,
tls: {
rejectUnauthorized: false, // Skip TLS verification for proxy
},
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello via https proxy no verify");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello via https proxy no verify");
gc();
});
});
// Squid proxy tests - run when Docker is enabled
// Uses docker-compose infrastructure to run squid proxy
// Import docker-compose dynamically to avoid issues when not using docker
const dockerCompose = require("../../../docker/index.ts");
describe.skipIf(!isDockerEnabled())("WebSocket through Squid proxy (Docker)", () => {
let squidInfo: { host: string; ports: Record<number, number>; proxyUrl?: string };
beforeAll(async () => {
console.log("Starting squid proxy container...");
squidInfo = await dockerCompose.ensure("squid");
console.log(`Squid proxy ready at: ${squidInfo.host}:${squidInfo.ports[3128]}`);
}, 120_000);
afterAll(async () => {
if (!process.env.BUN_KEEP_DOCKER) {
await dockerCompose.down();
}
}, 30_000);
test("ws:// through squid proxy to local server", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const proxyUrl = `http://${squidInfo.host}:${squidInfo.ports[3128]}`;
// Connect to our local WebSocket server through squid
const ws = new WebSocket(`ws://host.docker.internal:${wsPort}`, {
proxy: proxyUrl,
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello from bun via squid");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from bun via squid");
gc();
}, 30_000);
test("wss:// through squid proxy to local server", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const proxyUrl = `http://${squidInfo.host}:${squidInfo.ports[3128]}`;
// Connect to our local secure WebSocket server through squid
const ws = new WebSocket(`wss://host.docker.internal:${wssPort}`, {
proxy: proxyUrl,
tls: {
rejectUnauthorized: false, // Accept self-signed cert
},
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello wss from bun via squid");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello wss from bun via squid");
gc();
}, 30_000);
});
describe("ws module with HttpsProxyAgent", () => {
// These tests verify that the ws module (src/js/thirdparty/ws.js) correctly
// passes the agent property to the native WebSocket
const WS = require("ws");
test("ws module passes agent to native WebSocket", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`);
const ws = new WS(`ws://127.0.0.1:${wsPort}`, { agent });
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello from ws module via agent");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from ws module via agent");
gc();
});
test("ws module passes agent with TLS options", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`, {
rejectUnauthorized: false,
});
const ws = new WS(`wss://127.0.0.1:${wssPort}`, { agent });
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello from ws module via agent to wss");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from ws module via agent to wss");
gc();
});
test("ws module explicit proxy takes precedence over agent", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
// Create agent pointing to wrong port
const agent = new HttpsProxyAgent(`http://127.0.0.1:1`);
// But use explicit proxy option with correct port
const ws = new WS(`ws://127.0.0.1:${wsPort}`, {
agent,
proxy: `http://127.0.0.1:${proxyPort}`, // This should take precedence
});
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("ws module explicit proxy wins");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("ws module explicit proxy wins");
gc();
});
});
describe("WebSocket with HttpsProxyAgent", () => {
test("ws:// through HttpsProxyAgent", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`);
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent });
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello from WebSocket via HttpsProxyAgent");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from WebSocket via HttpsProxyAgent");
gc();
});
test("wss:// through HttpsProxyAgent with rejectUnauthorized", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`, {
rejectUnauthorized: false,
});
const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, { agent });
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello from wss via HttpsProxyAgent");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from wss via HttpsProxyAgent");
gc();
});
test("HttpsProxyAgent with authentication", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const agent = new HttpsProxyAgent(`http://proxy_user:proxy_pass@127.0.0.1:${authProxyPort}`);
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent });
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello from WebSocket with auth via HttpsProxyAgent");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from WebSocket with auth via HttpsProxyAgent");
gc();
});
test("HttpsProxyAgent with agent.proxy as URL object", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
// HttpsProxyAgent stores the proxy URL as a URL object in agent.proxy
const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`);
// Verify the agent has the proxy property as a URL object
expect(agent.proxy).toBeDefined();
expect(typeof agent.proxy).toBe("object");
expect(agent.proxy.href).toContain(`127.0.0.1:${proxyPort}`);
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent });
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello via agent with URL object");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello via agent with URL object");
gc();
});
test("explicit proxy option takes precedence over agent", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
// Create agent pointing to wrong port (that doesn't exist)
const agent = new HttpsProxyAgent(`http://127.0.0.1:1`);
// But use explicit proxy option with correct port
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
agent,
proxy: `http://127.0.0.1:${proxyPort}`, // This should take precedence
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("explicit proxy wins");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("explicit proxy wins");
gc();
});
});

View File

@@ -51,7 +51,7 @@
"happy-dom": "17.0.3",
"hono": "4.7.2",
"http2-wrapper": "2.2.1",
"https-proxy-agent": "7.0.5",
"https-proxy-agent": "7.0.6",
"iconv-lite": "0.6.3",
"immutable": "5.1.3",
"isbot": "5.1.13",