mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
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:
80
packages/bun-types/bun.d.ts
vendored
80
packages/bun-types/bun.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
25
src/bun.js/bindings/headers.h
generated
25
src/bun.js/bindings/headers.h
generated
@@ -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);
|
||||
|
||||
@@ -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)>)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(®ister, .{ .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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
71
src/http/websocket_client/WebSocketProxy.zig
Normal file
71
src/http/websocket_client/WebSocketProxy.zig
Normal 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");
|
||||
351
src/http/websocket_client/WebSocketProxyTunnel.zig
Normal file
351
src/http/websocket_client/WebSocketProxyTunnel.zig
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
74
src/js/thirdparty/ws.js
vendored
74
src/js/thirdparty/ws.js
vendored
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
20
test/docker/config/squid.conf
Normal file
20
test/docker/config/squid.conf
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
600
test/js/first_party/ws/ws-proxy.test.ts
Normal file
600
test/js/first_party/ws/ws-proxy.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
197
test/js/web/websocket/proxy-test-utils.ts
Normal file
197
test/js/web/websocket/proxy-test-utils.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
911
test/js/web/websocket/websocket-proxy.test.ts
Normal file
911
test/js/web/websocket/websocket-proxy.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user