diff --git a/.gitignore b/.gitignore index b555be3..b07ff25 100644 --- a/.gitignore +++ b/.gitignore @@ -362,3 +362,22 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd /src/TlsClient.Core/runtimes/win-x64/native/tls-client-latest.dll + +# TlsClient.NET — generated artifacts that must never be committed: +# src/native/TlsClient.Native.{os}-{arch}/ — platform package directories +# rendered from src/native/template/ +# by build/native-builder/prepare.js +# build/temp/ — staging directory for native DLLs +# downloaded by the prepare-libraries +# pipeline before they are placed into +# the rendered platform packages +# local-nuget/ — output of local `dotnet pack` runs +# used to consume the wrapper from +# another solution as a local feed +# /runtimes/ — ad-hoc DLL staging at the repo root +# (e.g. for NativeLoader.GetLibraryPath() +# default lookup during local debugging) +/src/native/TlsClient.Native.*/ +/build/temp/ +/local-nuget/ +/runtimes/ diff --git a/build/native-builder/prepare.js b/build/native-builder/prepare.js index 188b3e7..e4ff85f 100644 --- a/build/native-builder/prepare.js +++ b/build/native-builder/prepare.js @@ -5,6 +5,10 @@ const fsPromises = fs.promises; // Define paths based on environment variables const basePath = process.env.GITHUB_WORKSPACE; const tlsVersion = process.env.TLS_CLIENT_VERSION?.replace('v', '') || ''; +// PACKAGE_VERSION is the NuGet package version stamped into the platform-specific +// project file. Defaults to TLS_CLIENT_VERSION when not set so existing CI runs +// (which only set TLS_CLIENT_VERSION) keep working unchanged. +const packageVersion = process.env.PACKAGE_VERSION || tlsVersion; const tlsClientRegex = new RegExp(`tls-client-(.*)-(.*)-${tlsVersion}\\.(.*)`); // Set up paths @@ -111,6 +115,7 @@ async function processLibraries() { os: lib.os, arch: lib.arch, version: tlsVersion, + packageVersion: packageVersion, ext: lib.ext, runtimeIdentifier: lib.runtimeIdentifier, }; diff --git a/docs/streaming.md b/docs/streaming.md new file mode 100644 index 0000000..ece6d54 --- /dev/null +++ b/docs/streaming.md @@ -0,0 +1,214 @@ +# 🔀 Streaming Responses (SSE / chunked) + +`TlsClient.Native` can consume long-lived streaming responses — Server-Sent +Events (`text/event-stream`), NDJSON firehoses, or any chunked HTTP/1.1 / HTTP/2 +body — without buffering the entire body in memory first. + +This is **native-only**. `TlsClient.Api` doesn't expose it because the +upstream `tls-client` HTTP service doesn't forward chunked bodies in a +streaming fashion. + +--- + +## 🧱 The streaming API + +A streaming response is consumed through four short, synchronous calls on +`NativeTlsClient`. Each maps 1:1 to a native entry point in the underlying +`tls-client` library: + +| `NativeTlsClient` method | Native export | What it does | +| ------------------------------------------------- | --------------- | --------------------------------------------------------------------------- | +| `RequestStream(Request)` | `requestStream` | Issues the request and returns once **headers** are available; body stays open and is owned by the native side. | +| `ReadStream(Guid streamId, int timeoutMs)` | `readStream` | Returns the next chunk (base64-encoded bytes), or one of `EOF` / `Timeout` / `Error`. | +| `ReadStreamAll(Guid streamId)` | `readStreamAll` | Drains the rest of the body in one call. Returns the same `Response` shape as the non-streaming `Request()`. | +| `CancelStream(Guid streamId)` | `cancelStream` | Closes the underlying connection and releases native resources. Idempotent — safe to call after EOF, error, or on an unknown id. | + +`async` variants (`RequestStreamAsync`, `ReadStreamAsync`, `ReadStreamAllAsync`, +`CancelStreamAsync`) follow the same contract. + +The static `NativeTlsClient.IsStreamingSupported` reports whether the loaded +native library exposes the four entry points. Older `tls-client` builds may +not, in which case the streaming methods throw `EntryPointNotFoundException` +on first use: + +```csharp +if (!NativeTlsClient.IsStreamingSupported) + throw new NotSupportedException("Loaded native library does not expose the streaming entry points."); +``` + +--- + +## 🚀 Quick Start + +### Server-Sent Events + +```csharp +using TlsClient.Native; +using TlsClient.Core.Models.Entities; +using TlsClient.Core.Models.Requests; +using System.Text; + +NativeTlsClient.Initialize("{PATH_TO_NATIVE_LIBRARY}"); + +using var client = new NativeTlsClient(new TlsClientOptions( + TlsClientIdentifier.Chrome133, + "Mozilla/5.0 ... Chrome/133") +{ + // ⚠️ A negative duration disables the native http.Client.Timeout entirely. + // Required for long-lived SSE streams. TimeSpan.Zero does NOT work — it + // is treated as "use the 30 s default" by the native side. + Timeout = System.Threading.Timeout.InfiniteTimeSpan, +}); + +var start = client.RequestStream(new Request +{ + RequestUrl = "https://example.com/sse", + RequestMethod = HttpMethod.Get, + Headers = new() { ["Accept"] = "text/event-stream" }, +}); + +try +{ + var sb = new StringBuilder(); + while (true) + { + var chunk = client.ReadStream(start.StreamId, timeoutMs: 1000); + + if (!string.IsNullOrEmpty(chunk.Error)) + throw new IOException($"stream error: {chunk.Error}"); + + if (chunk.Timeout) + { + // No data for 1s. Check your CancellationToken here, then continue. + ct.ThrowIfCancellationRequested(); + continue; + } + + sb.Append(Encoding.UTF8.GetString(chunk.GetChunkBytes())); + + // Pull complete SSE events (`\n\n`-separated) out of the buffer: + int sep; + while ((sep = sb.ToString().IndexOf("\n\n", StringComparison.Ordinal)) >= 0) + { + string ev = sb.ToString(0, sep); + sb.Remove(0, sep + 2); + ProcessEvent(ev); + } + + if (chunk.EOF) break; + } +} +finally +{ + client.CancelStream(start.StreamId); // idempotent +} +``` + +### "I don't know upfront if it's streaming" + +When the same code path may receive either an SSE response *or* a regular JSON +body, branch on `Content-Type` from the headers `RequestStream` returns: + +```csharp +var start = client.RequestStream(req); + +string ct = start.Headers?.GetValueOrDefault("Content-Type")?.FirstOrDefault() ?? ""; +if (ct.Contains("text/event-stream") || ct.Contains("application/x-ndjson")) +{ + // Streaming — loop ReadStream as in the SSE example above. +} +else +{ + // Not streaming — drain the body in one extra FFI call. + Response full = client.ReadStreamAll(start.StreamId); + Console.WriteLine(full.Body); +} +``` + +The non-streaming branch costs **one extra FFI call** vs. plain `Request()` — +negligible compared to the network round-trip and avoids the "predict ahead of +time" problem. + +--- + +## 🔁 The `timeoutMs` parameter on `ReadStream` + +| Value | Behaviour | +| --------- | ------------------------------------------------------------------------------------------ | +| `< 0` | Block until the next chunk, EOF, or error. | +| `= 0` | Non-blocking poll — returns `Timeout=true` immediately if no chunk is buffered. | +| `> 0` | Block up to `timeoutMs` ms, then return `Timeout=true` if no chunk arrived (heartbeat). | + +`Timeout=true` is **not** an error — it just means "no data yet." Use it to +periodically check a `CancellationToken` from your read loop without blocking +the underlying P/Invoke thread indefinitely. + +--- + +## 🧩 Single-thread tunneling + +A common pattern with `TlsClient.Native` is to serialize every native call +through a single dedicated thread (to keep the cgo boundary simple). The +streaming API fits that model: + +* `RequestStream`, `ReadStream`, `ReadStreamAll`, and `CancelStream` are all + short-lived calls bounded by `timeoutMs`. +* The Go side keeps a per-stream goroutine that pumps body bytes into a + buffered channel — `ReadStream` just pops the next chunk. +* You can safely interleave streaming reads with normal `Request()` calls on + the same dispatcher. + +--- + +## ⚙️ Tuning + +| Knob | Where | Effect | +| ---- | ----- | ------ | +| `Request.StreamOutputBlockSize` | per request | Per-Read buffer size on the Go side. Defaults to **4096 bytes**. SSE rarely benefits from making it smaller; large binary streams may benefit from `64 * 1024`. | +| `ReadStream(streamId, timeoutMs)` | per call | How often the read loop wakes up to check cancellation. Typical: **250–1000 ms**. | +| `Request.TimeoutMilliseconds` / `TlsClientOptions.Timeout` | per client/request | ⚠️ **Set to `Timeout.InfiniteTimeSpan` (or any negative duration) for long-lived streams.** Go's `http.Client.Timeout` covers the entire request *including* body reads, so a 30 s timeout will tear down a long-lived SSE stream. `TimeSpan.Zero` / `TimeoutMilliseconds = 0` is treated as "use the 30 s default" — only a negative value disables the deadline. | + +--- + +## 📖 Reference (returned types) + +### `StreamStartResponse` +Extends `Response` with one extra field: `Guid StreamId`. `Body` is always empty. + +### `StreamChunkResponse` +| Field | Meaning | +| ------------ | ------------------------------------------------------------------------- | +| `StreamId` | Echo of the input id. | +| `Chunk` | Base64-encoded raw bytes (decompressed). Empty on EOF / Timeout / Error. | +| `EOF` | `true` once. Stream is closed; `StreamId` is invalid after this. | +| `Timeout` | `true` when no data arrived within `timeoutMs`. Stream still live. | +| `Error` | Non-empty on a fatal read error. Stream is closed. | +| `IsTerminal` | Convenience: `EOF || !string.IsNullOrEmpty(Error)`. | +| `GetChunkBytes()` | `Convert.FromBase64String(Chunk)` with a null-safe fallback. | + +`CancelStream` returns the existing `DestroyResponse` (`{ Id, Body, Success }`). + +--- + +## 🚧 Limitations + +* **No callbacks across the FFI boundary** — the model is poll-based, by + design. Reverse P/Invoke into managed code from arbitrary Go goroutine + threads is fragile and would break the single-thread tunneling pattern. +* **Backpressure timeout** — if you stop calling `ReadStream` for >60 s, the + Go-side pump goroutine cancels itself to avoid leaking goroutines. Subsequent + `ReadStream` calls will return `EOF=true`. +* **One reader per stream** — concurrent `ReadStream` calls on the same + `StreamId` are serialized internally but produce undefined chunk ordering. + Don't do it. + +--- + +## 🧯 Troubleshooting + +| Symptom | Likely cause | +| -------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `EntryPointNotFoundException: requestStream` | The loaded native library predates the streaming exports. Use a v1.11.2-stream or later DLL. | +| Stream ends after exactly 30 s | The native side enforces its default 30 s deadline. Set `TlsClientOptions.Timeout = Timeout.InfiniteTimeSpan` (or `Request.TimeoutMilliseconds = -1`) to disable it. Note: `TimeSpan.Zero` / `0` is interpreted as "use the default", not "no timeout". | +| Endless `Timeout=true` heartbeats | The remote side hasn't flushed any bytes yet. Verify the endpoint actually streams. | +| `Error: unknown streamId` | You called `ReadStream` after `EOF` / `Error` / `CancelStream`. Stop the loop on those. | diff --git a/readme.md b/readme.md index 6ec5f59..6e96646 100644 --- a/readme.md +++ b/readme.md @@ -113,6 +113,17 @@ Console.WriteLine(res.Status); --- +## 🔀 Streaming Responses + +`TlsClient.Native` supports consuming streaming responses (Server-Sent Events, +NDJSON, chunked bodies) without buffering the whole body — through a +`RequestStream` / `ReadStream` / `CancelStream` API on `NativeTlsClient`. + +See [docs/streaming.md](./docs/streaming.md) for the full guide, including SSE +examples, `Content-Type`-based branching, tuning knobs, and limitations. + +--- + ## 🧯 Support & Issues * Wrapper/packaging issues → open an issue **here**. diff --git a/src/Providers/TlsClient.Provider.HttpClient/README.md b/src/Providers/TlsClient.Provider.HttpClient/README.md index 74da184..e203a43 100644 --- a/src/Providers/TlsClient.Provider.HttpClient/README.md +++ b/src/Providers/TlsClient.Provider.HttpClient/README.md @@ -31,7 +31,7 @@ TlsClient.Initialize("{LIBRARY_PATH}"); // create a TlsClient instance var tlsClient = new TlsClientBuilder() - .WithIdentifier(TlsClientIdentifier.Chrome132) + .WithIdentifier(TlsClientIdentifier.Chrome133) .WithUserAgent("TestClient 1.0") .Build(); diff --git a/src/TlsClient.Api/ApiTlsClient.cs b/src/TlsClient.Api/ApiTlsClient.cs index 271a39b..9f25e4a 100644 --- a/src/TlsClient.Api/ApiTlsClient.cs +++ b/src/TlsClient.Api/ApiTlsClient.cs @@ -27,7 +27,7 @@ public ApiTlsClient(ApiTlsClientOptions options) : base(options) { HttpClient.DefaultRequestHeaders.Add("x-api-key", options.ApiKey); } - public ApiTlsClient(Uri apiBaseUri, string apiKey) : this(new ApiTlsClientOptions(TlsClientIdentifier.Chrome133, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0", apiBaseUri, apiKey)){ } + public ApiTlsClient(Uri apiBaseUri, string apiKey) : this(new ApiTlsClientOptions(TlsClientIdentifier.Chrome133, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0", apiBaseUri, apiKey)){ } #region Sync Methods public override Response Request(Request request) => AsyncHelpers.RunSync(() => RequestAsync(request, CancellationToken.None)); diff --git a/src/TlsClient.Api/Models/Entities/ApiTlsClientOptions.cs b/src/TlsClient.Api/Models/Entities/ApiTlsClientOptions.cs index 8dbf071..bc39979 100644 --- a/src/TlsClient.Api/Models/Entities/ApiTlsClientOptions.cs +++ b/src/TlsClient.Api/Models/Entities/ApiTlsClientOptions.cs @@ -8,7 +8,7 @@ public class ApiTlsClientOptions : TlsClientOptions public Uri ApiBaseUri { get; internal set; } public string ApiKey { get; internal set; } - public ApiTlsClientOptions(Uri apiBaseUri, string apiKey) : base(TlsClientIdentifier.Chrome133, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0") + public ApiTlsClientOptions(Uri apiBaseUri, string apiKey) : base(TlsClientIdentifier.Chrome133, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0") { ApiBaseUri = ValidateBaseUri(apiBaseUri, nameof(apiBaseUri)); ApiKey = ValidateApiKey(apiKey, nameof(apiKey)); diff --git a/src/TlsClient.Api/README.md b/src/TlsClient.Api/README.md index 7ad3ef9..4f2b05d 100644 --- a/src/TlsClient.Api/README.md +++ b/src/TlsClient.Api/README.md @@ -44,7 +44,7 @@ using TlsClient.Core.Models.Requests; var options = new ApiTlsClientOptions( TlsClientIdentifier.Chrome133, - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0", new Uri("http://127.0.0.1:8080"), "my-auth-key-1" ); diff --git a/src/TlsClient.Core/BaseTlsClient.cs b/src/TlsClient.Core/BaseTlsClient.cs index 1692602..df8bbbf 100644 --- a/src/TlsClient.Core/BaseTlsClient.cs +++ b/src/TlsClient.Core/BaseTlsClient.cs @@ -19,7 +19,7 @@ protected BaseTlsClient(TlsClientOptions options) Options = options ?? throw new ArgumentNullException(nameof(options)); } - protected BaseTlsClient(): this(new TlsClientOptions(TlsClientIdentifier.Chrome132, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0")){} + protected BaseTlsClient(): this(new TlsClientOptions(TlsClientIdentifier.Chrome133, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0")){} public abstract Response Request(Request request); public abstract GetCookiesFromSessionResponse GetCookies(string url); diff --git a/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs b/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs index c43444d..a242a9c 100644 --- a/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs +++ b/src/TlsClient.Core/Models/Entities/TlsClientIdentifier.cs @@ -21,12 +21,18 @@ public sealed class TlsClientIdentifier public static readonly TlsClientIdentifier Chrome124 = new TlsClientIdentifier("chrome_124"); public static readonly TlsClientIdentifier Chrome130Psk = new TlsClientIdentifier("chrome_130_PSK"); public static readonly TlsClientIdentifier Chrome131 = new TlsClientIdentifier("chrome_131"); - public static readonly TlsClientIdentifier Chrome132 = new TlsClientIdentifier("chrome_132"); public static readonly TlsClientIdentifier Chrome131Psk = new TlsClientIdentifier("chrome_131_PSK"); public static readonly TlsClientIdentifier Chrome133 = new TlsClientIdentifier("chrome_133"); public static readonly TlsClientIdentifier Chrome133Psk = new TlsClientIdentifier("chrome_133_PSK"); public static readonly TlsClientIdentifier Chrome144 = new TlsClientIdentifier("chrome_144"); public static readonly TlsClientIdentifier Chrome144Psk = new TlsClientIdentifier("chrome_144_PSK"); + public static readonly TlsClientIdentifier Chrome146 = new TlsClientIdentifier("chrome_146"); + public static readonly TlsClientIdentifier Chrome146Psk = new TlsClientIdentifier("chrome_146_PSK"); + #endregion + + #region Brave Profiles + public static readonly TlsClientIdentifier Brave146 = new TlsClientIdentifier("brave_146"); + public static readonly TlsClientIdentifier Brave146Psk = new TlsClientIdentifier("brave_146_PSK"); #endregion #region Safari Profiles @@ -39,6 +45,7 @@ public sealed class TlsClientIdentifier public static readonly TlsClientIdentifier SafariIos170 = new TlsClientIdentifier("safari_ios_17_0"); public static readonly TlsClientIdentifier SafariIos180 = new TlsClientIdentifier("safari_ios_18_0"); public static readonly TlsClientIdentifier SafariIos185 = new TlsClientIdentifier("safari_ios_18_5"); + public static readonly TlsClientIdentifier SafariIos260 = new TlsClientIdentifier("safari_ios_26_0"); #endregion #region Firefox Profiles @@ -57,6 +64,7 @@ public sealed class TlsClientIdentifier public static readonly TlsClientIdentifier Firefox146Psk = new TlsClientIdentifier("firefox_146_PSK"); public static readonly TlsClientIdentifier Firefox147 = new TlsClientIdentifier("firefox_147"); public static readonly TlsClientIdentifier Firefox147Psk = new TlsClientIdentifier("firefox_147_PSK"); + public static readonly TlsClientIdentifier Firefox148 = new TlsClientIdentifier("firefox_148"); #endregion #region Opera Profiles diff --git a/src/TlsClient.Core/Models/Entities/TlsClientOptions.cs b/src/TlsClient.Core/Models/Entities/TlsClientOptions.cs index e2cdd06..31b2824 100644 --- a/src/TlsClient.Core/Models/Entities/TlsClientOptions.cs +++ b/src/TlsClient.Core/Models/Entities/TlsClientOptions.cs @@ -12,6 +12,16 @@ public class TlsClientOptions public TlsClientIdentifier? TlsClientIdentifier { get; set; } = null; public string? ProxyURL { get; set; } public bool IsRotatingProxy { get; set; } = false; + /// + /// Per-request timeout applied to the whole HTTP exchange — including + /// body reads — by the underlying native client. Forwarded to the + /// native side as . + /// + /// Default: 60 s. + /// — interpreted by the native side as "use the 30 s default", NOT "no timeout". + /// (or any negative duration) — disables the deadline. Required for long-lived SSE / NDJSON / streaming responses, otherwise the connection will be torn down at the deadline. + /// + /// public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(60); public string? ServerNameOverwrite { get; set; } public bool FollowRedirects { get; set; } = false; diff --git a/src/TlsClient.Core/Models/Requests/CancelStreamRequest.cs b/src/TlsClient.Core/Models/Requests/CancelStreamRequest.cs new file mode 100644 index 0000000..46e9f1e --- /dev/null +++ b/src/TlsClient.Core/Models/Requests/CancelStreamRequest.cs @@ -0,0 +1,10 @@ +using System; + +namespace TlsClient.Core.Models.Requests +{ + // Reference: https://github.com/bogdanfinn/tls-client/blob/master/cffi_src/types.go (CancelStreamInput) + public class CancelStreamRequest + { + public Guid StreamId { get; set; } + } +} diff --git a/src/TlsClient.Core/Models/Requests/ReadStreamAllRequest.cs b/src/TlsClient.Core/Models/Requests/ReadStreamAllRequest.cs new file mode 100644 index 0000000..1c155e9 --- /dev/null +++ b/src/TlsClient.Core/Models/Requests/ReadStreamAllRequest.cs @@ -0,0 +1,10 @@ +using System; + +namespace TlsClient.Core.Models.Requests +{ + // Reference: https://github.com/bogdanfinn/tls-client/blob/master/cffi_src/types.go (ReadStreamAllInput) + public class ReadStreamAllRequest + { + public Guid StreamId { get; set; } + } +} diff --git a/src/TlsClient.Core/Models/Requests/ReadStreamRequest.cs b/src/TlsClient.Core/Models/Requests/ReadStreamRequest.cs new file mode 100644 index 0000000..4a439cd --- /dev/null +++ b/src/TlsClient.Core/Models/Requests/ReadStreamRequest.cs @@ -0,0 +1,20 @@ +using System; + +namespace TlsClient.Core.Models.Requests +{ + // Reference: https://github.com/bogdanfinn/tls-client/blob/master/cffi_src/types.go (ReadStreamInput) + public class ReadStreamRequest + { + public Guid StreamId { get; set; } + + /// + /// How long the native side should block waiting for the next chunk: + /// + /// < 0: block until the next chunk, EOF, or error. + /// = 0: non-blocking poll. Returns Timeout=true immediately when no chunk is buffered. + /// > 0: block up to TimeoutMs. Returns Timeout=true if no chunk arrived in time. + /// + /// + public int TimeoutMs { get; set; } + } +} diff --git a/src/TlsClient.Core/Models/Requests/Request.cs b/src/TlsClient.Core/Models/Requests/Request.cs index dfd8ef2..34844c2 100644 --- a/src/TlsClient.Core/Models/Requests/Request.cs +++ b/src/TlsClient.Core/Models/Requests/Request.cs @@ -26,15 +26,66 @@ public class Request public string? RequestBody { get; set; } = null; public string? RequestHostOverride { get; set; } = null; public Guid? SessionId { get; set; } + + /// + /// Per-Read buffer size used by the native side when writing to + /// or when chunking the body for the + /// streaming RequestStream / ReadStream API. Defaults to + /// 1024 bytes for stream-to-file and 4096 bytes for the streaming API. + /// public int? StreamOutputBlockSize { get; set; } = null; + + /// + /// Optional sentinel string appended to + /// after the body has been fully written, so consumers tailing the file + /// can detect completion without polling for size. Ignored when + /// is null. + /// public string? StreamOutputEOFSymbol { get; set; } = null; + + /// + /// When set, the response body is streamed to this file path on the + /// native side instead of being returned in + /// ; + /// is empty when this is non-null. + /// Bytes are written as the body arrives (after any automatic + /// decompression by the underlying HTTP transport), preserving the + /// original byte stream — binary content such as images is byte-exact. + /// + /// + /// The file is opened with O_APPEND | O_WRONLY | O_CREATE; if + /// the target already exists the new bytes are appended to it. The + /// caller is responsible for truncating or removing a stale file + /// before the request. This is independent of the streaming + /// RequestStream / ReadStream API on + /// NativeTlsClient; both can produce a file, but only the + /// streaming API hands chunks back to the .NET caller incrementally. + /// public string? StreamOutputPath { get; set; } = null; [JsonConverter(typeof(JsonStringConverter))] public HttpMethod RequestMethod { get; set; } = HttpMethod.Get; public string RequestUrl { get; set; } = string.Empty; public List? HeaderOrder { get; set; } = new List(); public List? RequestCookies { get; set; } = null; + /// + /// Per-request timeout in milliseconds. Takes precedence over + /// when both are set. Semantics: + /// + /// null or 0 — fall through to the native default (30 s). + /// > 0 — explicit deadline applied to the whole request, including body reads. + /// < 0 — disables the deadline entirely. Required for long-lived SSE / streaming responses. + /// + /// When forwarded from , + /// serializes to -1 here. + /// public int? TimeoutMilliseconds { get; set; } = null; + + /// + /// Per-request timeout in seconds. Yields to + /// when both are non-zero. Same three-way semantics as + /// : null/0 = native default, positive = deadline, + /// negative = disabled. + /// public int? TimeoutSeconds { get; set; } = null; public bool? CatchPanics { get; set; } = null; public bool? FollowRedirects { get; set; } = null; diff --git a/src/TlsClient.Core/Models/Responses/StreamChunkResponse.cs b/src/TlsClient.Core/Models/Responses/StreamChunkResponse.cs new file mode 100644 index 0000000..848e5a7 --- /dev/null +++ b/src/TlsClient.Core/Models/Responses/StreamChunkResponse.cs @@ -0,0 +1,48 @@ +using System; +using System.Text.Json.Serialization; + +namespace TlsClient.Core.Models.Responses +{ + // Reference: https://github.com/bogdanfinn/tls-client/blob/master/cffi_src/types.go (StreamChunkResponse) + /// + /// Returned by readStream. Exactly one of , , + /// , or a non-empty indicates the meaningful state: + /// + /// (base64) holds the next slice of decompressed body bytes. + /// = true means the stream completed naturally; the + /// is invalid after this call. + /// = true means no data was available within the + /// caller-provided timeout; the stream is still live and the caller may retry. + /// holds a non-empty message when the underlying read failed; + /// the is invalid after this call. + /// + /// + public class StreamChunkResponse : BaseResponse + { + public Guid StreamId { get; set; } + + /// Base64-encoded raw bytes. Empty when , , or + /// is set. Use for the decoded payload. + public string? Chunk { get; set; } + + [JsonPropertyName("eof")] + public bool EOF { get; set; } + + public bool Timeout { get; set; } + + public string? Error { get; set; } + + /// True when the chunk carries no payload (EOF/Timeout/Error or empty data). + [JsonIgnore] + public bool IsTerminal => EOF || !string.IsNullOrEmpty(Error); + + /// Decode the base64 into raw bytes. Returns an empty array if + /// the chunk is missing. + public byte[] GetChunkBytes() + { + if (string.IsNullOrEmpty(Chunk)) + return Array.Empty(); + return Convert.FromBase64String(Chunk); + } + } +} diff --git a/src/TlsClient.Core/Models/Responses/StreamStartResponse.cs b/src/TlsClient.Core/Models/Responses/StreamStartResponse.cs new file mode 100644 index 0000000..a4d2537 --- /dev/null +++ b/src/TlsClient.Core/Models/Responses/StreamStartResponse.cs @@ -0,0 +1,16 @@ +using System; + +namespace TlsClient.Core.Models.Responses +{ + // Reference: https://github.com/bogdanfinn/tls-client/blob/master/cffi_src/types.go (StreamStartResponse) + /// + /// Returned by requestStream. Carries the same fields as + /// (status, headers, cookies, target, used protocol) plus a + /// that identifies the open stream for subsequent ReadStream / ReadStreamAll / + /// CancelStream calls. is always empty here. + /// + public class StreamStartResponse : Response + { + public Guid StreamId { get; set; } + } +} diff --git a/src/TlsClient.Core/TlsClient.Core.csproj b/src/TlsClient.Core/TlsClient.Core.csproj index 08ac512..90c4baf 100644 --- a/src/TlsClient.Core/TlsClient.Core.csproj +++ b/src/TlsClient.Core/TlsClient.Core.csproj @@ -2,6 +2,10 @@ netstandard2.1 enable + 1.2.0-stream.1 + ErenKrt + https://github.com/ErenKrt/TlsClient.NET + Shared models and helpers for TlsClient.NET. diff --git a/src/TlsClient.Native/NativeTlsClient.cs b/src/TlsClient.Native/NativeTlsClient.cs index dcaac8c..b4435d7 100644 --- a/src/TlsClient.Native/NativeTlsClient.cs +++ b/src/TlsClient.Native/NativeTlsClient.cs @@ -88,5 +88,167 @@ public override DestroyResponse DestroyAll() public override Task DestroyAllAsync(CancellationToken ct = default) => AsyncHelpers.RunAsync(() => DestroyAll(), ct); public override Task AddCookiesAsync(string url, List cookies, CancellationToken ct = default) => AsyncHelpers.RunAsync(() => AddCookies(url, cookies), ct); #endregion + + #region Streaming + /// + /// Returns true when the loaded native library exposes the streaming exports. + /// Older builds may not — in which case the streaming methods on this client throw + /// on first use. + /// + public static bool IsStreamingSupported => TlsClientWrapper.IsStreamingSupported; + + /// + /// Issues a request and returns once the response headers are available, without + /// reading the body. Use the returned + /// to drive subsequent / / + /// calls. + /// + /// + /// For long-lived streaming responses (server-sent events, NDJSON, etc.) + /// you must disable the native side's deadline by setting + /// to + /// — or by setting + /// / + /// to a negative value. + /// applies to the whole request including body reads, and the value 0 + /// is interpreted as "use the 30 s default", not "no timeout". + /// + public StreamStartResponse RequestStream(Request request) + { + request = PrepareRequest(request); + + StreamStartResponse response; + + try + { + var payload = RequestHelpers.Prepare(request); + var rawResponse = TlsClientWrapper.RequestStream(payload); + response = rawResponse.FromJson() ?? throw new Exception("Response is null, can't convert object from json."); + } + catch (Exception err) + { + response = new StreamStartResponse + { + Body = err.Message, + Status = 0, + }; + } + + if (!string.IsNullOrEmpty(response.Id)) + TlsClientWrapper.FreeMemory(response.Id); + + return response; + } + + /// + /// Polls for the next chunk of an in-flight stream. Returns immediately on data, + /// EOF, error, or after when no chunk is available + /// (heartbeat poll). Inspect , + /// , and + /// to drive the read loop. + /// + /// + /// < 0: block until the next chunk, EOF, or error. + /// = 0: non-blocking poll. + /// > 0: block up to TimeoutMs, then return Timeout=true if no chunk arrived. + /// + public StreamChunkResponse ReadStream(Guid streamId, int timeoutMs = 1000) + { + StreamChunkResponse response; + + try + { + var payload = RequestHelpers.Prepare(new ReadStreamRequest + { + StreamId = streamId, + TimeoutMs = timeoutMs, + }); + var rawResponse = TlsClientWrapper.ReadStream(payload); + response = rawResponse.FromJson() ?? throw new Exception("Response is null, can't convert object from json."); + } + catch (Exception err) + { + response = new StreamChunkResponse + { + StreamId = streamId, + Error = err.Message, + }; + } + + if (!string.IsNullOrEmpty(response.Id)) + TlsClientWrapper.FreeMemory(response.Id); + + return response; + } + + /// + /// Drains the rest of the stream's body in one call and returns it as a normal + /// (charset-decoded for text bodies, base64+MIME prefix for + /// byte responses). Use this when the response from + /// turns out to NOT be a streaming response (e.g. Content-Type isn't + /// text/event-stream) and you just want the full body in one shot. + /// After this returns, is invalid. + /// + public Response ReadStreamAll(Guid streamId) + { + Response response; + + try + { + var payload = RequestHelpers.Prepare(new ReadStreamAllRequest { StreamId = streamId }); + var rawResponse = TlsClientWrapper.ReadStreamAll(payload); + response = rawResponse.FromJson() ?? throw new Exception("Response is null, can't convert object from json."); + } + catch (Exception err) + { + response = new Response + { + Body = err.Message, + Status = 0, + }; + } + + if (!string.IsNullOrEmpty(response.Id)) + TlsClientWrapper.FreeMemory(response.Id); + + return response; + } + + /// + /// Cancels an in-flight stream and releases the underlying connection. Idempotent — + /// safe to call after a natural EOF, after an error, or with an unknown + /// . Always call this in a finally block when consuming + /// chunks via . + /// + public DestroyResponse CancelStream(Guid streamId) + { + DestroyResponse response; + + try + { + var payload = RequestHelpers.Prepare(new CancelStreamRequest { StreamId = streamId }); + var rawResponse = TlsClientWrapper.CancelStream(payload); + response = rawResponse.FromJson() ?? throw new Exception("Response is null, can't convert object from json."); + } + catch (Exception err) + { + response = new DestroyResponse + { + Body = err.Message, + Success = false, + }; + } + + if (!string.IsNullOrEmpty(response.Id)) + TlsClientWrapper.FreeMemory(response.Id); + + return response; + } + + public Task RequestStreamAsync(Request request, CancellationToken ct = default) => AsyncHelpers.RunAsync(() => RequestStream(request), ct); + public Task ReadStreamAsync(Guid streamId, int timeoutMs = 1000, CancellationToken ct = default) => AsyncHelpers.RunAsync(() => ReadStream(streamId, timeoutMs), ct); + public Task ReadStreamAllAsync(Guid streamId, CancellationToken ct = default) => AsyncHelpers.RunAsync(() => ReadStreamAll(streamId), ct); + public Task CancelStreamAsync(Guid streamId, CancellationToken ct = default) => AsyncHelpers.RunAsync(() => CancelStream(streamId), ct); + #endregion } } diff --git a/src/TlsClient.Native/TlsClient.Native.csproj b/src/TlsClient.Native/TlsClient.Native.csproj index 6ccecfe..3dd45a4 100644 --- a/src/TlsClient.Native/TlsClient.Native.csproj +++ b/src/TlsClient.Native/TlsClient.Native.csproj @@ -5,8 +5,8 @@ enable TlsClient.Native ErenKrt + 1.2.0-stream.1 https://github.com/ErenKrt/TlsClient.NET - True TlsClient.Native is a .NET Standard library that provides direct/native bindings to the TlsClient Go library. README.md LICENSE diff --git a/src/TlsClient.Native/Wrappers/TlsClientWrapper.cs b/src/TlsClient.Native/Wrappers/TlsClientWrapper.cs index c1c08ab..8ab8014 100644 --- a/src/TlsClient.Native/Wrappers/TlsClientWrapper.cs +++ b/src/TlsClient.Native/Wrappers/TlsClientWrapper.cs @@ -24,6 +24,19 @@ public static class TlsClientWrapper [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate IntPtr DestroyAllDelegate(); + // Streaming exports (added in tls-client v1.11.2-stream) + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr RequestStreamDelegate(byte[] payload); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr ReadStreamDelegate(byte[] payload); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr ReadStreamAllDelegate(byte[] payload); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr CancelStreamDelegate(byte[] payload); + private static bool _isInitialized; private static IntPtr _module; @@ -33,6 +46,12 @@ public static class TlsClientWrapper private static AddCookiesToSessionDelegate _addCookiesToSessionDelegate = null!; private static DestroySessionDelegate _destroySessionDelegate = null!; private static DestroyAllDelegate _destroyAllDelegate = null!; + // Nullable: may remain null after Initialize when the loaded native library predates + // the streaming exports. RequireStreamingDelegate enforces non-null on first use. + private static RequestStreamDelegate? _requestStreamDelegate; + private static ReadStreamDelegate? _readStreamDelegate; + private static ReadStreamAllDelegate? _readStreamAllDelegate; + private static CancelStreamDelegate? _cancelStreamDelegate; public static void Initialize(string? libraryPath = null) { @@ -52,6 +71,13 @@ public static void Initialize(string? libraryPath = null) _destroySessionDelegate = GetDelegate("destroySession"); _destroyAllDelegate = GetDelegate("destroyAll"); + // Streaming exports — optional. Older native libraries don't expose these, + // so the lookup is allowed to fail silently. The streaming wrapper methods + // re-check and throw a clearer error when actually invoked. + _requestStreamDelegate = TryGetDelegate("requestStream"); + _readStreamDelegate = TryGetDelegate("readStream"); + _readStreamAllDelegate = TryGetDelegate("readStreamAll"); + _cancelStreamDelegate = TryGetDelegate("cancelStream"); _isInitialized = true; } @@ -72,6 +98,25 @@ private static T GetDelegate(string functionName) where T : Delegate return Marshal.GetDelegateForFunctionPointer(functionPtr); } + private static T? TryGetDelegate(string functionName) where T : Delegate + { + var functionPtr = NativeLoader.GetProcAddress(_module, functionName); + if (functionPtr == IntPtr.Zero) + return null; + + return Marshal.GetDelegateForFunctionPointer(functionPtr); + } + + private static T RequireStreamingDelegate(T? del, string functionName) where T : Delegate + { + if (del is null) + throw new EntryPointNotFoundException( + $"Native function '{functionName}' is not exported by the loaded tls-client library. " + + "Streaming requires a native library built with the streaming exports (requestStream, " + + "readStream, readStreamAll, cancelStream). Update your native library."); + return del; + } + private static string ExecuteNative(Func nativeCall) { var ptr = nativeCall(); @@ -123,6 +168,56 @@ public static string DestroyAll() EnsureInitialized(); return ExecuteNative(() => _destroyAllDelegate()); } + + public static string RequestStream(byte[] payload) + { + EnsureInitialized(); + if (payload is null) throw new ArgumentNullException(nameof(payload)); + var del = RequireStreamingDelegate(_requestStreamDelegate, "requestStream"); + return ExecuteNative(() => del(payload)); + } + + public static string ReadStream(byte[] payload) + { + EnsureInitialized(); + if (payload is null) throw new ArgumentNullException(nameof(payload)); + var del = RequireStreamingDelegate(_readStreamDelegate, "readStream"); + return ExecuteNative(() => del(payload)); + } + + public static string ReadStreamAll(byte[] payload) + { + EnsureInitialized(); + if (payload is null) throw new ArgumentNullException(nameof(payload)); + var del = RequireStreamingDelegate(_readStreamAllDelegate, "readStreamAll"); + return ExecuteNative(() => del(payload)); + } + + public static string CancelStream(byte[] payload) + { + EnsureInitialized(); + if (payload is null) throw new ArgumentNullException(nameof(payload)); + var del = RequireStreamingDelegate(_cancelStreamDelegate, "cancelStream"); + return ExecuteNative(() => del(payload)); + } + + /// + /// True when the loaded native library exposes the streaming exports + /// (requestStream, readStream, readStreamAll, cancelStream). + /// Older builds of the tls-client library may not expose these, in which case the streaming + /// methods on will throw on first use. + /// + public static bool IsStreamingSupported + { + get + { + EnsureInitialized(); + return _requestStreamDelegate != null + && _readStreamDelegate != null + && _readStreamAllDelegate != null + && _cancelStreamDelegate != null; + } + } public static void Destroy() { if (!_isInitialized) @@ -146,6 +241,10 @@ public static void Destroy() _addCookiesToSessionDelegate = null!; _destroySessionDelegate = null!; _destroyAllDelegate = null!; + _requestStreamDelegate = null; + _readStreamDelegate = null; + _readStreamAllDelegate = null; + _cancelStreamDelegate = null; _module = IntPtr.Zero; diff --git a/src/native/template/{title}.csproj b/src/native/template/{title}.csproj index 00e08e8..d69738f 100644 --- a/src/native/template/{title}.csproj +++ b/src/native/template/{title}.csproj @@ -2,9 +2,9 @@ netstandard2.1 false - true {title} ErenKrt + {packageVersion} https://github.com/ErenKrt/TlsClient.NET Native client of TLSClient for {os} {arch} diff --git a/tests/TlsClient.Api.Tests/HeaderTests.cs b/tests/TlsClient.Api.Tests/HeaderTests.cs index 9e9e00c..0e205b3 100644 --- a/tests/TlsClient.Api.Tests/HeaderTests.cs +++ b/tests/TlsClient.Api.Tests/HeaderTests.cs @@ -51,7 +51,7 @@ public void Should_Add_UserAgent_Header_Options() [Fact] public void Should_Add_UserAgent_Header_Default() { - var userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0"; + var userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0"; using var tlsClient = new ApiTlsClient(new Uri("http://127.0.0.1:8080"), "my-auth-key-1"); var request = new Request() diff --git a/tests/TlsClient.Native.RestSharp.Tests/BodyTests.cs b/tests/TlsClient.Native.RestSharp.Tests/BodyTests.cs index f44c1d6..405fa2d 100644 --- a/tests/TlsClient.Native.RestSharp.Tests/BodyTests.cs +++ b/tests/TlsClient.Native.RestSharp.Tests/BodyTests.cs @@ -15,7 +15,7 @@ public class BodyTests { static BodyTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.RestSharp.Tests/CookieTests.cs b/tests/TlsClient.Native.RestSharp.Tests/CookieTests.cs index 019a0da..74bfdd7 100644 --- a/tests/TlsClient.Native.RestSharp.Tests/CookieTests.cs +++ b/tests/TlsClient.Native.RestSharp.Tests/CookieTests.cs @@ -17,7 +17,7 @@ public class CookieTests { static CookieTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.RestSharp.Tests/MethodTests.cs b/tests/TlsClient.Native.RestSharp.Tests/MethodTests.cs index 414691e..188b64e 100644 --- a/tests/TlsClient.Native.RestSharp.Tests/MethodTests.cs +++ b/tests/TlsClient.Native.RestSharp.Tests/MethodTests.cs @@ -15,7 +15,7 @@ public class MethodTests { static MethodTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.RestSharp.Tests/NativeTestSetup.cs b/tests/TlsClient.Native.RestSharp.Tests/NativeTestSetup.cs new file mode 100644 index 0000000..2f917fc --- /dev/null +++ b/tests/TlsClient.Native.RestSharp.Tests/NativeTestSetup.cs @@ -0,0 +1,20 @@ +using System; + +namespace TlsClient.RestSharp.Tests +{ + /// + /// Resolves the path to the native tls-client library used by the Native + /// RestSharp test fixtures. Reads the TLS_CLIENT_NATIVE_DLL + /// environment variable when set; otherwise falls back to a developer-machine + /// default that the upstream maintainer uses locally. CI / contributors + /// should set the env var to point at the DLL produced for their platform. + /// + internal static class NativeTestSetup + { + public const string EnvVar = "TLS_CLIENT_NATIVE_DLL"; + public const string DefaultDllPath = "D:\\Tools\\tls-client-windows-64-1.13.1.dll"; + + public static string DllPath => + Environment.GetEnvironmentVariable(EnvVar) ?? DefaultDllPath; + } +} diff --git a/tests/TlsClient.Native.RestSharp.Tests/StatusTests.cs b/tests/TlsClient.Native.RestSharp.Tests/StatusTests.cs index ac0d63b..0f72321 100644 --- a/tests/TlsClient.Native.RestSharp.Tests/StatusTests.cs +++ b/tests/TlsClient.Native.RestSharp.Tests/StatusTests.cs @@ -16,7 +16,7 @@ public class StatusTests { static StatusTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/BodyTests.cs b/tests/TlsClient.Native.Tests/BodyTests.cs index e168a85..96ab3f1 100644 --- a/tests/TlsClient.Native.Tests/BodyTests.cs +++ b/tests/TlsClient.Native.Tests/BodyTests.cs @@ -16,7 +16,7 @@ public class BodyTests { static BodyTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/BuilderTests.cs b/tests/TlsClient.Native.Tests/BuilderTests.cs index f805cf1..33aa22b 100644 --- a/tests/TlsClient.Native.Tests/BuilderTests.cs +++ b/tests/TlsClient.Native.Tests/BuilderTests.cs @@ -17,7 +17,7 @@ public class BuilderTests { static BuilderTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } diff --git a/tests/TlsClient.Native.Tests/ClientTests.cs b/tests/TlsClient.Native.Tests/ClientTests.cs index 38d53a0..b0a883d 100644 --- a/tests/TlsClient.Native.Tests/ClientTests.cs +++ b/tests/TlsClient.Native.Tests/ClientTests.cs @@ -10,7 +10,7 @@ public class ClientTests { static ClientTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/CookieTests.cs b/tests/TlsClient.Native.Tests/CookieTests.cs index c756a34..85a058d 100644 --- a/tests/TlsClient.Native.Tests/CookieTests.cs +++ b/tests/TlsClient.Native.Tests/CookieTests.cs @@ -17,7 +17,7 @@ public class CookieTests static CookieTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/HeaderTests.cs b/tests/TlsClient.Native.Tests/HeaderTests.cs index 13e5c1b..405c6d4 100644 --- a/tests/TlsClient.Native.Tests/HeaderTests.cs +++ b/tests/TlsClient.Native.Tests/HeaderTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; +using System.Net.Sockets; using System.Text; using System.Threading.Tasks; using TlsClient.Core.Models.Entities; @@ -14,7 +16,7 @@ public class HeaderTests public static readonly string BaseURL = "https://httpbin.io"; static HeaderTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] @@ -54,7 +56,7 @@ public void Should_Add_UserAgent_Header_Options() [Fact] public void Should_Add_UserAgent_Header_Default() { - var userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 OPR/117.0.0.0"; + var userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 OPR/118.0.0.0"; using var tlsClient = new NativeTlsClient(); var request = new Request() @@ -84,18 +86,23 @@ public void Should_Add_UserAgent_After_Ctor() [Fact] public void Should_Override_Host() { - var baseHost = "httpbin.org"; - var realIp = "http://35.169.229.34"; + // Connect to the literal IP so DNS does not produce the Host header + // for us — that's what makes this an actual test of RequestHostOverride. + // Resolve the IP at runtime so the test self-heals when the upstream + // CDN / load balancer rotates addresses. + const string baseHost = "httpbin.org"; + var ip = Dns.GetHostAddresses(baseHost) + .First(a => a.AddressFamily == AddressFamily.InterNetwork); using var tlsClient = new NativeTlsClient(); var request = new Request() { - RequestUrl = realIp, - RequestHostOverride= baseHost, - InsecureSkipVerify= true + RequestUrl = $"http://{ip}", + RequestHostOverride = baseHost, + InsecureSkipVerify = true, }; var response = tlsClient.Request(request); - Assert.Contains($"httpbin", response.Body); + Assert.Contains("httpbin", response.Body); } } } \ No newline at end of file diff --git a/tests/TlsClient.Native.Tests/MethodTests.cs b/tests/TlsClient.Native.Tests/MethodTests.cs index af2ef1e..dde2379 100644 --- a/tests/TlsClient.Native.Tests/MethodTests.cs +++ b/tests/TlsClient.Native.Tests/MethodTests.cs @@ -9,7 +9,7 @@ public class MethodTests public static readonly string BaseURL = "https://httpbin.io"; static MethodTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/NativeTestSetup.cs b/tests/TlsClient.Native.Tests/NativeTestSetup.cs new file mode 100644 index 0000000..6d35092 --- /dev/null +++ b/tests/TlsClient.Native.Tests/NativeTestSetup.cs @@ -0,0 +1,20 @@ +using System; + +namespace TlsClient.Core.Tests +{ + /// + /// Resolves the path to the native tls-client library used by the Native + /// test fixtures. Reads the TLS_CLIENT_NATIVE_DLL environment variable + /// when set; otherwise falls back to a developer-machine default that the + /// upstream maintainer uses locally. CI / contributors should set the env + /// var to point at the DLL produced for their platform. + /// + internal static class NativeTestSetup + { + public const string EnvVar = "TLS_CLIENT_NATIVE_DLL"; + public const string DefaultDllPath = "D:\\Tools\\tls-client-windows-64-1.13.1.dll"; + + public static string DllPath => + Environment.GetEnvironmentVariable(EnvVar) ?? DefaultDllPath; + } +} diff --git a/tests/TlsClient.Native.Tests/PerformanceTests.cs b/tests/TlsClient.Native.Tests/PerformanceTests.cs index 2e25ded..4edae8c 100644 --- a/tests/TlsClient.Native.Tests/PerformanceTests.cs +++ b/tests/TlsClient.Native.Tests/PerformanceTests.cs @@ -17,7 +17,7 @@ public class PerformanceTests { static PerformanceTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] @@ -36,7 +36,7 @@ public async Task Should_Perform_Multiple_Requests() { using var tlsClient = new NativeTlsClient(new TlsClientOptions { - TlsClientIdentifier = TlsClientIdentifier.Chrome132, + TlsClientIdentifier = TlsClientIdentifier.Chrome133, Timeout = TimeSpan.FromSeconds(10) }); @@ -80,7 +80,7 @@ public async Task Should_Not_Leak_Memory() { using var tlsClient = new NativeTlsClient(new TlsClientOptions { - TlsClientIdentifier = TlsClientIdentifier.Chrome132, + TlsClientIdentifier = TlsClientIdentifier.Chrome133, Timeout = TimeSpan.FromSeconds(10) }); diff --git a/tests/TlsClient.Native.Tests/StatusTests.cs b/tests/TlsClient.Native.Tests/StatusTests.cs index b43abe4..7b0ab44 100644 --- a/tests/TlsClient.Native.Tests/StatusTests.cs +++ b/tests/TlsClient.Native.Tests/StatusTests.cs @@ -10,7 +10,7 @@ public class StatusTests public static readonly string BaseURL = "https://httpbin.io"; static StatusTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] diff --git a/tests/TlsClient.Native.Tests/TlsTests.cs b/tests/TlsClient.Native.Tests/TlsTests.cs index db94620..4fa12af 100644 --- a/tests/TlsClient.Native.Tests/TlsTests.cs +++ b/tests/TlsClient.Native.Tests/TlsTests.cs @@ -15,7 +15,7 @@ public class TlsTests { static TlsTests() { - NativeTlsClient.Initialize("D:\\Tools\\tls-client-windows-64-1.13.1.dll"); + NativeTlsClient.Initialize(NativeTestSetup.DllPath); } [Fact] @@ -37,7 +37,7 @@ public void Should_Skip_Verify_Ssl() using var tlsClient = new NativeTlsClient(new TlsClientOptions() { InsecureSkipVerify = true, - TlsClientIdentifier = TlsClientIdentifier.Chrome132, + TlsClientIdentifier = TlsClientIdentifier.Chrome133, }); var request = new Request() { @@ -53,7 +53,7 @@ public void Should_Force_Http1() using var tlsClient = new NativeTlsClient(new TlsClientOptions() { ForceHttp1 = true, - TlsClientIdentifier = TlsClientIdentifier.Chrome132, + TlsClientIdentifier = TlsClientIdentifier.Chrome133, }); var request = new Request() { @@ -69,7 +69,7 @@ public void Should_Not_Force_Http1() { using var tlsClient = new NativeTlsClient(new TlsClientOptions() { - TlsClientIdentifier = TlsClientIdentifier.Chrome132, + TlsClientIdentifier = TlsClientIdentifier.Chrome133, }); var request = new Request() { diff --git a/tls-client-version.txt b/tls-client-version.txt index 07fb54b..08e9010 100644 --- a/tls-client-version.txt +++ b/tls-client-version.txt @@ -1 +1 @@ -v1.11.2 +v1.14.0-stream \ No newline at end of file