winhttp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2934f4cdb7b7aa87cd433359d2e99b8b69e9235308cfdcf26642de8ffa015e87
4
+ data.tar.gz: dccaaf39953e68065d89ac8d4ca484f4e46d7f8dc2d4cb67678849816c093ca1
5
+ SHA512:
6
+ metadata.gz: 78132ed5d2be6e6fd8cbaeb8a8cb5f6ccf348f13702449f46befdac21f9eb95588b62bcd4232fd9c97bdd1796b075cefdce1da873d65b07549999351bd5f3205
7
+ data.tar.gz: 155e80d64b8b05c412294665b8c649677b75d52e1ca99a81ceaea0c0b028be1aa6a095fc1137fab97611bc5a6c16d044dc5396f277d06c4bb167df03be34bb3d
data/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-06-27
4
+
5
+ Initial release.
6
+
7
+ - **`Winhttp::Session`** wraps one asynchronous WinHTTP session
8
+ (`WINHTTP_FLAG_ASYNC`) with system TLS via Schannel, the OS certificate store,
9
+ and the user's proxy/PAC settings. Options: `user_agent:`, `proxy:`
10
+ (`:system`/`:none`/a String), `proxy_bypass:`, `http2:`, `decompress:`,
11
+ `revocation:` (`:best_effort`/`:strict`/`:none`), `redirects:`,
12
+ `connect_timeout:`/`send_timeout:`/`receive_timeout:`. A TLS 1.2 floor is
13
+ always enforced (TLS 1.3 when the OS supports it). Thread-safe and fiber-safe.
14
+ - **Module one-liners** `Winhttp.get`, `Winhttp.post`, `Winhttp.request`
15
+ delegate to a lazily-created, process-lifetime `Winhttp.default_session`.
16
+ - **Request verbs** `get`, `head`, `post`, `put`, `patch`, `delete`, and
17
+ arbitrary `request(method, url, ...)`, with `headers:`, `body:`, `timeout:`,
18
+ and a streaming `&chunk` block (each chunk a fresh ASCII-8BIT String ≤ 64 KiB,
19
+ yielded on the caller's thread/fiber).
20
+ - **`Winhttp::Response`**: `status`, `reason`, `headers` (lowercased, duplicates
21
+ joined), `raw_headers` (wire order/case/duplicates), `#[]` (case-insensitive),
22
+ `body` (ASCII-8BIT, or nil when streamed), `text` (charset-aware tagging),
23
+ `final_url`, `http2?`, `success?`. 4xx/5xx are responses, not exceptions.
24
+ - **Error hierarchy** `Winhttp::Error` → `OSError` (with `#code`) →
25
+ `TimeoutError`, `ResolveError`, `ConnectError`, `TlsError` (with `#details`),
26
+ `RedirectError`, `ProtocolError`, `Canceled`; plus `Closed`. A Ruby-level
27
+ `timeout:` deadline raises `TimeoutError` with `code == nil`.
28
+ - **winloop cooperation, one code path.** WinHTTP's status callbacks are bridged
29
+ through a gem-private completion queue and a single pump thread into
30
+ per-request `Thread::Queue` mailboxes; the mailbox pop blocks a plain thread
31
+ natively and parks a fiber under a `Fiber::Scheduler` (e.g. winloop) — with no
32
+ scheduler branches anywhere in winhttp and no winloop dependency.
33
+ - **Safe lifetime by construction**: per-request native buffers are freed only
34
+ on the guaranteed-last `HANDLE_CLOSING` callback; request identity is a
35
+ never-reused uint64 id; `Session#close` aborts in-flight requests and bounds
36
+ its wait for OS teardown before closing the session handle.
37
+
38
+ Windows MSVC (mswin) Ruby only. x64 (`x64-mswin64`); arm64-mswin expected to
39
+ work but untested.
40
+
41
+ [0.1.0]: https://github.com/main-path/winhttp/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ned
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # winhttp
2
+
3
+ **HTTP for Ruby on the stack Windows already ships: WinHTTP async with system TLS, the OS certificate store, and the user's proxy settings — fiber-friendly under winloop, plain blocking without it.**
4
+
5
+ On native Windows Ruby, the usual HTTP stack means `net/http` plus the `openssl`
6
+ gem: you bundle and patch a *second* TLS implementation, you ignore the OS
7
+ certificate store, and you get nothing from the corporate proxy / PAC settings
8
+ the machine is already configured for. The `openssl` gem also has to be built
9
+ and kept current independently of Windows Update.
10
+
11
+ winhttp is a thin binding to **the HTTP client Windows already ships** —
12
+ [WinHTTP](https://learn.microsoft.com/windows/win32/winhttp/about-winhttp) in
13
+ asynchronous mode. Certificate validation, revocation policy, redirects with
14
+ safe defaults, proxy auto-detection, HTTP/2 negotiation, and transparent
15
+ gzip/deflate are **the OS's**, serviced by Windows Update — not ours. Same suite
16
+ logic as phylax ("binds the crypto Windows already ships, implements none of its
17
+ own"): winhttp binds the HTTP stack Windows already ships and implements none of
18
+ it. It is not a full HTTP framework — see [Scope and limitations](#scope-and-limitations).
19
+
20
+ | What | API |
21
+ | --- | --- |
22
+ | One-liners | `Winhttp.get(url)` · `Winhttp.post(url, body:)` · `Winhttp.request(verb, url)` |
23
+ | Sessions | `Winhttp::Session.new(...)` · `Session.open(...) { \|s\| ... }` · `s.get/head/post/put/patch/delete/request` |
24
+ | Streaming | `s.get(url) { \|chunk\| ... }` (each chunk ≤ 64 KiB, on your thread/fiber) |
25
+ | Responses | `r.status` · `r.headers` · `r.raw_headers` · `r[name]` · `r.body` · `r.text` · `r.final_url` · `r.http2?` · `r.success?` |
26
+ | Errors | `Winhttp::TlsError#details`, `ResolveError`, `ConnectError`, `TimeoutError`, `RedirectError`, `ProtocolError`, `Canceled`, `Closed` |
27
+
28
+ ## Requirements
29
+
30
+ - **Windows 10 1607+ recommended** (HTTP/2 needs 1607; earlier Win10 degrades to
31
+ HTTP/1.1 silently). TLS 1.3 needs Windows 11 / Server 2022; older Windows uses
32
+ TLS 1.2 (winhttp enforces a TLS 1.2 floor and never enables anything older).
33
+ - A native **MSVC (mswin) Ruby**, **3.2+** (`Thread::Queue#pop(timeout:)` is the
34
+ mailbox deadline primitive). **Not supported on MinGW/UCRT** Ruby.
35
+ - Visual Studio 2017+ / Build Tools with the **"Desktop development with C++"**
36
+ workload. No Developer Command Prompt is needed — the build uses the `vcvars`
37
+ gem; if `rake compile` can't find the toolchain, run `vcvars doctor`.
38
+ - Supported platform: x64 (`x64-mswin64`). arm64-mswin is expected to work (the
39
+ code is `_WIN64`/`uintptr_t`-clean, no arch-specific anything) but is untested
40
+ and unsupported until an arm64-mswin Ruby distribution exists.
41
+
42
+ ## Install
43
+
44
+ ```sh
45
+ gem install winhttp
46
+ ```
47
+
48
+ ## Quick start
49
+
50
+ ```ruby
51
+ require "winhttp"
52
+
53
+ # One-liners on the shared default session
54
+ r = Winhttp.get("https://example.com/")
55
+ r.status # => 200
56
+ r.headers["content-type"] # => "text/html; charset=UTF-8"
57
+ r.text[0, 15] # => "<!doctype html>"
58
+
59
+ # POST JSON
60
+ r = Winhttp.post("https://httpbin.org/post",
61
+ body: '{"hello":"world"}',
62
+ headers: { "Content-Type" => "application/json" })
63
+ r.success? # => true
64
+ ```
65
+
66
+ ## Sessions and options
67
+
68
+ WinHTTP pools TCP/TLS connections at the **session**, so reuse one `Session` for
69
+ many requests. A `Session` is thread-safe and fiber-safe — any number of threads
70
+ and/or fibers may issue requests on it concurrently.
71
+
72
+ ```ruby
73
+ s = Winhttp::Session.new(
74
+ user_agent: "myapp/1.0",
75
+ proxy: :system, # :system | :none | "proxy.corp:8080"
76
+ proxy_bypass: nil, # only with a String proxy: "<local>;*.internal.corp"
77
+ http2: true, # enable HTTP/2 if the OS supports it (else HTTP/1.1)
78
+ decompress: true, # transparent gzip/deflate
79
+ revocation: :best_effort, # :best_effort | :strict | :none (see below)
80
+ redirects: 10, # 0..65535 max auto-redirects; 0 = return 3xx to you
81
+ connect_timeout: nil, # seconds | nil = WinHTTP default
82
+ send_timeout: nil,
83
+ receive_timeout: nil
84
+ )
85
+
86
+ Winhttp::Session.open(proxy: :none, redirects: 0) do |session| # ensure-closed
87
+ session.get("https://example.com/")
88
+ end
89
+ ```
90
+
91
+ TLS / revocation / certificate policy is **fixed at construction** (WinHTTP
92
+ caches certificate validation per session), so there are deliberately no
93
+ per-request TLS knobs.
94
+
95
+ | `revocation:` | Behavior |
96
+ | --- | --- |
97
+ | `:best_effort` (default) | Revocation on, but offline CRLs are forgiven (`IGNORE_CERT_REVOCATION_OFFLINE`). On a pre-2004 build that lacks the ignore-offline option, it **degrades to `:none`** at construction (availability over false-strictness). `Session#revocation` then returns `:none`. |
98
+ | `:strict` | Revocation on with no offline forgiveness — offline CRLs raise `TlsError` (`:cert_rev_failed`). If the OS cannot enable revocation, `Session.new` raises `OSError` (never silently weaker than asked). |
99
+ | `:none` | WinHTTP default (revocation off). |
100
+
101
+ `Session#revocation` reports the **effective** policy settled at construction —
102
+ security-minded users on old builds should check it.
103
+
104
+ ## Streaming downloads
105
+
106
+ Pass a block and each received body chunk is yielded as a fresh ASCII-8BIT
107
+ String (≤ 64 KiB each) on your thread/fiber, in order; `Response#body` is then
108
+ `nil`. If the block raises, the request is aborted and the exception propagates.
109
+
110
+ ```ruby
111
+ Winhttp::Session.open(receive_timeout: 30) do |s|
112
+ File.open("big.bin", "wb") do |f|
113
+ resp = s.get("https://example.com/big.bin", timeout: 300) { |chunk| f.write(chunk) }
114
+ resp.body # => nil (streamed)
115
+ resp.final_url # => "https://example.com/big.bin"
116
+ end
117
+ end
118
+ ```
119
+
120
+ ## Concurrency
121
+
122
+ Multiple plain threads sharing one `Session` get true concurrency — WinHTTP's
123
+ thread pool does the I/O; the GVL is only held for the tiny Ruby state-machine
124
+ steps.
125
+
126
+ ```ruby
127
+ s = Winhttp::Session.new
128
+ 16.times.map { |i| Thread.new { s.get("https://example.com/?#{i}") } }.each(&:join)
129
+ ```
130
+
131
+ Under [winloop](https://github.com/main-path/winloop) (the suite's IOCP
132
+ `Fiber::Scheduler`) the **same code** parks fibers instead of blocking threads:
133
+
134
+ ```ruby
135
+ require "winloop"
136
+ Winloop.run do
137
+ 10.times.map { |i| Fiber.schedule { Winhttp.get("https://example.com/?#{i}") } }
138
+ # ten requests in flight concurrently on one OS thread; the loop keeps running
139
+ end
140
+ ```
141
+
142
+ There are no `Fiber.scheduler` branches anywhere in winhttp — one code path
143
+ serves both modes (see [How it works](#how-it-works)).
144
+
145
+ ## Timeouts and cancellation
146
+
147
+ Two layers, both available:
148
+
149
+ - **Session socket timeouts** (`connect_timeout` / `send_timeout` /
150
+ `receive_timeout`) are WinHTTP's own and surface as `TimeoutError` with
151
+ `code == 12002`.
152
+ - A per-request **`timeout:`** is a Ruby-level wall-clock deadline for the
153
+ *entire* call (resolve → connect → TLS → send → headers → full body). On
154
+ expiry the request is aborted and `TimeoutError` is raised with `code == nil`.
155
+
156
+ ```ruby
157
+ Winhttp.get("https://example.com/", timeout: 0.000001)
158
+ # raises Winhttp::TimeoutError (code nil — Ruby-side deadline)
159
+ ```
160
+
161
+ `Timeout.timeout` and `Thread#kill` also unwind cleanly: the request handle is
162
+ aborted in the `ensure` path and the session stays fully usable.
163
+
164
+ ## Library API
165
+
166
+ ```ruby
167
+ # --- Module functions (delegate to Winhttp.default_session) ---
168
+ Winhttp.default_session # => Winhttp::Session (lazy, immortal)
169
+ Winhttp.get(url, headers: nil, timeout: nil, &chunk) # => Winhttp::Response
170
+ Winhttp.post(url, body: "", headers: nil, timeout: nil, &chunk) # => Winhttp::Response
171
+ Winhttp.request(method, url, body: nil, headers: nil, timeout: nil, &chunk) # => Winhttp::Response
172
+
173
+ # --- Session ---
174
+ Winhttp::Session.new(user_agent:, proxy:, proxy_bypass:, http2:, decompress:,
175
+ revocation:, redirects:, connect_timeout:, send_timeout:,
176
+ receive_timeout:) # => Session
177
+ Winhttp::Session.open(**opts) { |s| ... } # => block value (ensure-closes)
178
+ session.get(url, headers: nil, timeout: nil, &chunk) # => Response
179
+ session.head(url, headers: nil, timeout: nil) # => Response
180
+ session.post(url, body: "", headers: nil, timeout: nil, &chunk) # => Response
181
+ session.put(url, body:, headers: nil, timeout: nil, &chunk) # => Response
182
+ session.patch(url, body:, headers: nil, timeout: nil, &chunk) # => Response
183
+ session.delete(url, headers: nil, timeout: nil, &chunk) # => Response
184
+ session.request(method, url, body: nil, headers: nil, timeout: nil, &chunk) # => Response
185
+ session.close # => nil (idempotent)
186
+ session.closed? # => true | false
187
+ session.revocation # => :best_effort | :strict | :none (effective)
188
+
189
+ # --- Response (immutable) ---
190
+ response.status # => Integer (200, 404, ...)
191
+ response.reason # => String ("OK"; may be "" under HTTP/2)
192
+ response.headers # => Hash{String=>String} (keys lowercased, duplicates joined ", "; frozen)
193
+ response.raw_headers # => Array[[String, String]] (wire order, original case, duplicates kept; frozen)
194
+ response[name] # => String | nil (case-insensitive single-header lookup)
195
+ response.body # => String (ASCII-8BIT) | nil (nil when a &chunk block was used)
196
+ response.text(enc=nil) # => String (a copy tagged with enc / charset= / UTF-8; never transcodes)
197
+ response.final_url # => String (the URL actually fetched, after redirects)
198
+ response.http2? # => true | false
199
+ response.success? # => true | false ((200..299).cover?(status))
200
+ ```
201
+
202
+ ## Errors
203
+
204
+ 4xx/5xx are **responses, not exceptions** — `get` returns a `Response` for any
205
+ HTTP status. Exceptions are raised only for transport/TLS/protocol failures and
206
+ misuse. Plain argument misuse raises Ruby's own `ArgumentError`/`TypeError`.
207
+
208
+ ```
209
+ Winhttp::Error < StandardError # all winhttp failures
210
+ Winhttp::OSError < Error # carries #code (Integer | nil)
211
+ Winhttp::TimeoutError # 12002, or code nil for Ruby-side deadlines
212
+ Winhttp::ResolveError # 12007 (DNS)
213
+ Winhttp::ConnectError # 12029 / 12030 (TCP)
214
+ Winhttp::TlsError # 12175 + cert failures — see #details
215
+ Winhttp::RedirectError # 12156
216
+ Winhttp::ProtocolError # 12152 (malformed response)
217
+ Winhttp::Canceled # 12017 / 995 (aborted)
218
+ Winhttp::Closed < Error # misuse: session already closed
219
+ ```
220
+
221
+ `Winhttp::TlsError#details` decodes the `SECURE_FAILURE` flag bits captured just
222
+ before the failure:
223
+
224
+ | Symbol | Meaning |
225
+ | --- | --- |
226
+ | `:cert_rev_failed` | revocation check could not complete |
227
+ | `:invalid_cert` | certificate is invalid |
228
+ | `:cert_revoked` | certificate was revoked |
229
+ | `:invalid_ca` | unknown / untrusted CA |
230
+ | `:cert_cn_invalid` | hostname does not match the certificate |
231
+ | `:cert_date_invalid` | certificate is expired or not yet valid |
232
+ | `:security_channel_error` | other Schannel error |
233
+
234
+ ```ruby
235
+ Winhttp.get("https://expired.badssl.com/")
236
+ # raises Winhttp::TlsError, e.code => 12175, e.details => [:cert_date_invalid]
237
+ ```
238
+
239
+ ## How it works
240
+
241
+ WinHTTP runs in **async mode** (`WINHTTP_FLAG_ASYNC`): the OS does resolve /
242
+ connect / TLS / send / receive on its own thread pool and delivers completions
243
+ to a status callback. That callback runs on a foreign thread (or synchronously
244
+ inside one of our own calls) — where it may not touch Ruby or the GVL — so it
245
+ only appends a plain-C event node to a critical-section-guarded list and signals
246
+ an event. One lazily-started **pump thread** waits on that event (GVL released),
247
+ drains the list (GVL held), and routes each event to its request's
248
+ `Thread::Queue` mailbox. The per-request state machine pops that mailbox.
249
+
250
+ That mailbox pop is the only place a caller blocks — and it is exactly the
251
+ primitive a `Fiber::Scheduler` hooks: standalone it blocks the thread natively;
252
+ under winloop the pump's cross-thread `Queue#push` wakes the parked fiber. **One
253
+ code path, one extra thread total (the pump), zero winloop coupling** — fibers
254
+ park for free.
255
+
256
+ Per-request native memory (the body copy and the 64 KiB receive buffer) is freed
257
+ only on `HANDLE_CLOSING`, the guaranteed-last callback — never on a Ruby-side
258
+ path the kernel might still write into. Request identity is a never-reused uint64
259
+ id (never a pointer), so late or duplicate callbacks for a dead request are
260
+ dropped harmlessly. `Session#close` marks the session closed, aborts every
261
+ in-flight request, and waits (bounded) for the OS to finish teardown before
262
+ closing the session handle.
263
+
264
+ ## Scope and limitations
265
+
266
+ Honest bullets — winhttp is a thin OS binding, not a framework:
267
+
268
+ - **User-set headers cross redirects unchanged** — including `Authorization`,
269
+ which can therefore **leak to a redirect target**. v1 adds no header-stripping
270
+ magic; set `redirects: 0` if you need to inspect 3xx yourself. https→http
271
+ redirects are always refused (OS default).
272
+ - **Cookies are per-session and automatic** (WinHTTP's handling) — there is no
273
+ cookie-jar API or inspection.
274
+ - The **body is buffered** into `Response#body` unless you pass a streaming
275
+ block.
276
+ - `Response#headers` reflects the **wire** headers, except that WinHTTP removes
277
+ `Content-Encoding`/`Content-Length` once it transparently decompresses a
278
+ gzip/deflate body (so the decoded body and the headers stay consistent).
279
+ - **Not in v1:** authentication of any kind (Basic/Digest/NTLM/Negotiate/Kerberos,
280
+ proxy auth, credentials in URLs — actively rejected), WebSockets, HTTP/3,
281
+ client certificates (mutual TLS), upload streaming (whole-string bodies only),
282
+ per-request proxies/PAC, TLS escape hatches (no `insecure_skip_verify`, no
283
+ custom CA stores, no peer-cert accessor), response caps / retry / backoff /
284
+ caching, charset transcoding (`Response#text` tags, never transcodes).
285
+ - **Well-known non-HTTP ports** (21, 25, 110, …) are blocked by WinHTTP — the
286
+ original OS error surfaces as an `OSError`.
287
+ - **One Windows identity per session** — winhttp does no impersonation.
288
+
289
+ ## License
290
+
291
+ [MIT](LICENSE.txt).
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # extconf.rb for the winhttp extension — a thin binding to the asynchronous
4
+ # WinHTTP client API (sessions, requests, system TLS, proxy, status callbacks).
5
+
6
+ require "mkmf"
7
+
8
+ # Arch-neutral guard: match target_os only (passes on x64-mswin64 AND a future
9
+ # arm64-mswin64, whose target_os is still "mswin64"); never pin the x64 arch.
10
+ unless RbConfig::CONFIG["target_os"] =~ /mswin/
11
+ abort <<~MSG
12
+ winhttp requires a native Windows MSVC (mswin) Ruby — it binds the
13
+ asynchronous WinHTTP client API (system TLS via Schannel, the OS certificate
14
+ store, the user's proxy/PAC settings, HTTP/2 and gzip), and is built with
15
+ cl.exe. Your Ruby is "#{RbConfig::CONFIG['arch']}".
16
+ MSG
17
+ end
18
+
19
+ # System import libs (bare "NAME.lib" tokens on mswin):
20
+ # winhttp - the OS HTTP client (sessions, requests, TLS, proxy, callbacks)
21
+ # kernel32 (events, critical sections, FormatMessage) is linked by default.
22
+ # No /MACHINE, no -arch: let mkmf inherit Ruby's RbConfig flags (arm64-clean).
23
+ # Pure C — no -EHsc, so rb_raise/longjmp is the normal, safe error mechanism.
24
+ $libs = [$libs, "winhttp.lib"].join(" ")
25
+
26
+ create_makefile("winhttp/winhttp")