connectrpc 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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +501 -0
  4. data/docs/design.md +504 -0
  5. data/docs/development.md +566 -0
  6. data/docs/grpc-web.md +165 -0
  7. data/docs/grpc.md +75 -0
  8. data/docs/rbs.md +469 -0
  9. data/docs/streaming.md +251 -0
  10. data/exe/protoc-gen-connectrpc-ruby +23 -0
  11. data/lib/connectrpc/bindings/descriptors/status.bin +15 -0
  12. data/lib/connectrpc/bindings/google_protobuf.rb +92 -0
  13. data/lib/connectrpc/cancellation.rb +220 -0
  14. data/lib/connectrpc/client.rb +260 -0
  15. data/lib/connectrpc/compression.rb +120 -0
  16. data/lib/connectrpc/deadline.rb +81 -0
  17. data/lib/connectrpc/error.rb +72 -0
  18. data/lib/connectrpc/generator/names.rb +105 -0
  19. data/lib/connectrpc/generator/rbs_renderer.rb +93 -0
  20. data/lib/connectrpc/generator/renderer.rb +115 -0
  21. data/lib/connectrpc/generator/runtime_names.rb +84 -0
  22. data/lib/connectrpc/generator/schema.rb +218 -0
  23. data/lib/connectrpc/generator/streaming_methods.rb +29 -0
  24. data/lib/connectrpc/generator.rb +42 -0
  25. data/lib/connectrpc/get_query.rb +159 -0
  26. data/lib/connectrpc/grpc.rb +200 -0
  27. data/lib/connectrpc/grpc_client.rb +23 -0
  28. data/lib/connectrpc/grpc_server.rb +158 -0
  29. data/lib/connectrpc/grpc_web.rb +131 -0
  30. data/lib/connectrpc/grpc_web_cors.rb +135 -0
  31. data/lib/connectrpc/grpc_web_io.rb +156 -0
  32. data/lib/connectrpc/grpc_web_server.rb +207 -0
  33. data/lib/connectrpc/instrumentation.rb +216 -0
  34. data/lib/connectrpc/message_io.rb +47 -0
  35. data/lib/connectrpc/metadata.rb +105 -0
  36. data/lib/connectrpc/method.rb +68 -0
  37. data/lib/connectrpc/protocol.rb +189 -0
  38. data/lib/connectrpc/server.rb +398 -0
  39. data/lib/connectrpc/streaming/client.rb +104 -0
  40. data/lib/connectrpc/streaming/client_execution.rb +87 -0
  41. data/lib/connectrpc/streaming/client_scope.rb +105 -0
  42. data/lib/connectrpc/streaming/framing.rb +123 -0
  43. data/lib/connectrpc/streaming/framing_io.rb +61 -0
  44. data/lib/connectrpc/streaming/method.rb +33 -0
  45. data/lib/connectrpc/streaming/server.rb +122 -0
  46. data/lib/connectrpc/streaming/server_execution.rb +88 -0
  47. data/lib/connectrpc/streaming/server_terminal.rb +70 -0
  48. data/lib/connectrpc/streaming/session.rb +143 -0
  49. data/lib/connectrpc/streaming/session_completion.rb +134 -0
  50. data/lib/connectrpc/streaming/session_io.rb +107 -0
  51. data/lib/connectrpc/streaming/session_messages.rb +119 -0
  52. data/lib/connectrpc/streaming/strategy.rb +95 -0
  53. data/lib/connectrpc/streaming/streams.rb +116 -0
  54. data/lib/connectrpc/streaming/terminal.rb +52 -0
  55. data/lib/connectrpc/transports/net_http.rb +246 -0
  56. data/lib/connectrpc/version.rb +5 -0
  57. data/lib/connectrpc.rb +38 -0
  58. data/rbs/google_protobuf_4_36/runtime.rbs +41 -0
  59. data/sig/connectrpc.rbs +772 -0
  60. data/sig/manifest.yaml +2 -0
  61. data/third_party/README.md +16 -0
  62. data/third_party/google/LICENSE +201 -0
  63. data/third_party/google/rpc/status.proto +48 -0
  64. data/third_party/protobuf-LICENSE +32 -0
  65. metadata +106 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 444e77cbd9ce738f5babb41ea25029e57c004738282f18c16dc102f413b96125
4
+ data.tar.gz: 0bfe9f9329071885b05f80d6d768b30285332777e69f61b0cbe7bed0fbb6f424
5
+ SHA512:
6
+ metadata.gz: 1374476f0d1b5e4564a639dfff93938d9fb6d379199d090001d081c83f76851a7590dfafacd88fc50a8276e0636a44c0a5c62ae459dc926985933a3aea7ae89f
7
+ data.tar.gz: 811d31f2fa60d4be74906e1c7b121f0928e7158b18fe2ee1ce546571511f5624bbb41f4d2b9055d1a2ef1873bebb998bab80bd5826c7e3a4d8c01ec82dd7030f
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jim Myhrberg
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,501 @@
1
+ # connectrpc-ruby
2
+
3
+ An unofficial, experimental Ruby implementation of typed
4
+ [Connect](https://connectrpc.com/docs/protocol/) unary and native streaming RPCs. It provides a
5
+ configurable client, a Net::HTTP transport, and host-neutral servers for reusable
6
+ Ruby handlers. The optional [connectrpc-rack gem](gems/connectrpc-rack/README.md)
7
+ provides Rack middleware. It is not production ready.
8
+
9
+ Connect supports unary POST and opt-in safe unary GET, with identity or opt-in
10
+ gzip encoding and `application/proto` or protobuf `application/json`. The optional
11
+ [connectrpc-on-rails gem](gems/connectrpc-on-rails/README.md) runs generated unary
12
+ handlers through native Rails controllers.
13
+
14
+ Native Connect streaming uses the optional Async transport and Async/Falcon
15
+ HTTP/2 hosts. Falcon also serves Connect HTTP/1.1 server and client streaming.
16
+ The explicit Async `HTTP1Transport` supports unary, server streaming and client
17
+ streaming. See the [streaming guide](docs/streaming.md), including its one-second
18
+ HTTP tail policy for native Connect streaming clients.
19
+
20
+ Native [gRPC](docs/grpc.md) supports unary, server streaming, client streaming
21
+ and bidi through the same core declarations and Async/Falcon HTTP/2 engine,
22
+ with protobuf, identity/gzip, h2c, verified TLS and mTLS. Explicit server
23
+ [gRPC-Web](docs/grpc-web.md) supports unary and native server streaming within
24
+ its binary/text, host and compression limits. Connect remains the default.
25
+ Faraday remains unsupported. The API is experimental.
26
+
27
+ The [supported 0.x contract](docs/support.md) defines the target protocol, host,
28
+ platform and API compatibility boundaries. Adoption still depends on the open
29
+ [#26 findings](https://github.com/jimeh/connectrpc-ruby/issues/26) and release
30
+ qualification; current artifacts are not declared supported or production ready.
31
+
32
+ ## Try the localhost example
33
+
34
+ Install [Mise](https://mise.jdx.dev/) 2026.9.14 or newer and the
35
+ [Ruby source-build prerequisites](https://github.com/rbenv/ruby-build/wiki#suggested-build-environment),
36
+ then run:
37
+
38
+ ```sh
39
+ mise run setup
40
+ mise run example
41
+ mise run verify
42
+ mise run rack:verify
43
+ ```
44
+
45
+ [The example](examples/unary.rb) starts Rack on an ephemeral loopback port,
46
+ calls a typed handler through real Net::HTTP in both codecs, prints the results,
47
+ and closes the host and its workers. Its schema comes from the shared
48
+ [descriptor fixture](spec/fixtures/echo.proto). Go is needed only for interop
49
+ and final verification, and those tasks install its project pin separately.
50
+
51
+ To run a server and call it from another terminal, see the
52
+ [runnable examples](examples/README.md): Rack, Rails, Async and Falcon servers
53
+ with NetHTTP and Async clients, including mutual TLS.
54
+
55
+ ## Generate services from protobuf
56
+
57
+ `protoc-gen-connectrpc-ruby` generates mixed unary/streaming declarations, thin clients
58
+ and handler maps. Supply google-protobuf `>= 4.36.2, < 5` for the plugin and
59
+ Google message integration. The core gem still has zero runtime dependencies.
60
+ Run the complete `.proto` -> generated messages/service -> ephemeral localhost
61
+ example with:
62
+
63
+ ```sh
64
+ mise run example:generated
65
+ ```
66
+
67
+ It compiles [greeting.proto](examples/proto/greeting.proto) with protoc 36.2,
68
+ then [calls the generated client](examples/generated.rb) in binary and JSON,
69
+ including typed deliberate error details. To use the installed gem's plugin
70
+ in your own project, with `protoc-gen-connectrpc-ruby` on PATH:
71
+
72
+ ```sh
73
+ mkdir -p generated
74
+ protoc -I examples/proto --ruby_out=generated --connectrpc-ruby_out=generated greeting.proto
75
+ ```
76
+
77
+ This creates official `greeting_pb.rb` and `greeting_connect.rb`. Add `generated`
78
+ to the Ruby load path. The service API is:
79
+
80
+ ```ruby
81
+ require "greeting_connect"
82
+ require "connectrpc/bindings/google_protobuf"
83
+
84
+ service = Greeting::V1::GreetingService
85
+ binding = ConnectRPC::Bindings::GoogleProtobuf.new
86
+ core = ConnectRPC::Client.new(base_url: "http://127.0.0.1:8080", binding: binding, codec: :json)
87
+ client = service::Client.new(client: core)
88
+ response = client.greet(Greeting::V1::Greeting.new(name: "Ruby"), timeout: 2)
89
+
90
+ # Supply application callables accepting (request, context).
91
+ handlers = service.handlers(greet: greet_handler, fail: fail_handler)
92
+ server = ConnectRPC::Server.new(services: [service::SERVICE], handlers: handlers, binding: binding)
93
+ ```
94
+
95
+ `greet_handler` and `fail_handler` above are application-defined callables; the
96
+ [runnable example](examples/generated.rb) defines both and hosts the Rack app.
97
+ `GREET` and `FAIL` are frozen `ConnectRPC::Method` constants; `SERVICE` is reusable.
98
+ Wrapper calls return the existing `Response` and forward keyword options exactly,
99
+ including omission of `timeout:` versus explicit `timeout: nil`. Binding, transport,
100
+ codec, URL and limits belong to the configured core client. `handlers` requires
101
+ all declared RPC keywords and validates callability; use the existing Server
102
+ handler map directly for partial/custom registration.
103
+
104
+ Buf 1.75.0 local generation is tested alongside official message generation:
105
+
106
+ ```yaml
107
+ # buf.gen.yaml, for a Buf module containing greeting.proto
108
+ version: v2
109
+ plugins:
110
+ - protoc_builtin: ruby
111
+ out: generated
112
+ - local: protoc-gen-connectrpc-ruby
113
+ out: generated
114
+ ```
115
+
116
+ Run `buf generate` in that module with pinned protoc on PATH. For this checkout,
117
+ `mise run generation:check` runs both actual tools with explicit local paths,
118
+ compares glue bytes and exercises both generated message trees.
119
+
120
+ Proto2 and proto3, including optional presence and mixed unary/streaming
121
+ services, are supported. Editions and unsupported plugin parameters are rejected.
122
+ Default output is `dir/foo_connect.rb` for service-bearing `dir/foo.proto`.
123
+ Message generation remains official `--ruby_out`. Set `rbs=true` to also emit
124
+ `dir/foo_connect.rbs`.
125
+
126
+ The gem ships optional core signatures; the [RBS guide](docs/rbs.md) covers
127
+ official message `--rbs_out`, explicit protobuf bridge selection and strict Steep
128
+ configuration. Ruby module
129
+ placement follows official package/`ruby_package` naming; RPC methods use the
130
+ gRPC Ruby underscore rule (`GetHTTP` becomes `get_http`). Leading-underscore
131
+ RPCs, object hooks, implicit conversion methods, reserved runtime names and
132
+ collisions visible in the request fail with diagnostics. Compatible `google.*`
133
+ and `Google::...` Ruby package nesting is supported, including WKT imports;
134
+ replacing `Google`, `ConnectRPC` or concrete protobuf runtime constants is rejected.
135
+ Cross-invocation namespace conflicts cannot all be discovered; generated code
136
+ also guards its directly defined constants at load time. See [design](docs/design.md).
137
+
138
+ ## Use unary GET
139
+
140
+ Declare safe queries with the standard protobuf method option:
141
+
142
+ ```proto
143
+ rpc Read(Request) returns (Reply) {
144
+ option idempotency_level = NO_SIDE_EFFECTS;
145
+ }
146
+ ```
147
+
148
+ The generator maps `NO_SIDE_EFFECTS` to `idempotency: :no_side_effects`,
149
+ `IDEMPOTENT` to `:idempotent`, and an absent/unknown level to `:unknown`.
150
+ Manual `Method.new` declarations accept the same three symbols.
151
+ Enable GET on the core client; generated wrappers keep their existing API:
152
+
153
+ ```ruby
154
+ core = ConnectRPC::Client.new(base_url: "https://api.example.com", binding: binding,
155
+ use_get: true, get_url_limit: 8192)
156
+ ```
157
+
158
+ Only `:no_side_effects` methods select GET. The default remains POST.
159
+ The URL cap counts the complete absolute URI in bytes, including the base
160
+ prefix and escaped query. Excess raises `resource_exhausted` before dispatch;
161
+ there is no POST fallback, retry or replay. Binary and gzip payloads use
162
+ unpadded URL-safe base64; identity JSON uses UTF-8 form encoding.
163
+
164
+ Servers admit GET automatically for declared safe methods. Native hosts and
165
+ Testing forward the raw query; direct `Server.call` callers pass `query: "..."`
166
+ separately from `path:`. `Server.new(get_query_limit: nil)` derives its raw-query
167
+ cap as `3 * (4 * ((request_wire_limit + 2) / 3)) + 1024` after validating the
168
+ wire limit. An explicit cap must be a positive integer. Serialized/decompressed
169
+ message bytes, decoded query payload bytes before inflation, and raw query/URL
170
+ bytes have independent limits. Hosts have already allocated the incoming URL;
171
+ these caps are not a total memory guarantee.
172
+
173
+ GET URLs can appear in proxy/server logs, browser history and generic HTTP
174
+ instrumentation. Choose safe request contents and application cache/security
175
+ policy accordingly. All GET responses vary on `Accept-Encoding`, preserving
176
+ application Vary tokens and `*`. The library never adds public Cache-Control.
177
+ Authentication, cookies, sessions and CSRF remain application/framework policy.
178
+ Official Ruby protobuf serialization is repeatable for the same exercised
179
+ object within a process, including gzip. It has no deterministic flag or
180
+ cross-process canonical cache-key guarantee, including for maps.
181
+
182
+ ## Declare a unary method
183
+
184
+ Core `require "connectrpc"` needs no Google or Rack gems. Load optional
185
+ integrations explicitly. Development uses exact pins; the integration entry
186
+ points support google-protobuf `>= 4.36.2, < 5` and Rack `>= 3.2.7, < 4`.
187
+ Applications using Google must supply `google-protobuf`. Rack middleware ships
188
+ in the separate `connectrpc-rack` gem, which installs compatible core and Rack
189
+ dependencies. Existing middleware users must add this Gemfile dependency:
190
+
191
+ ```ruby
192
+ gem "connectrpc-rack", "~> 0.1.0"
193
+ ```
194
+
195
+ Keep `require "connectrpc/rack"` and the middleware API unchanged. Core installed
196
+ alone has no middleware file, so requiring that path raises an ordinary
197
+ `LoadError`. This is an installation migration; Rack already loaded only on
198
+ explicit require.
199
+
200
+ ```ruby
201
+ require "connectrpc/bindings/google_protobuf"
202
+ require "connectrpc/rack"
203
+ require "google/protobuf/wrappers_pb"
204
+
205
+ type = Google::Protobuf::StringValue
206
+ method = ConnectRPC::Method.new(
207
+ service: "example.v1.EchoService", name: "Echo",
208
+ request_type: type, response_type: type
209
+ )
210
+ service = ConnectRPC::Service.new(name: method.service, methods: [method])
211
+ binding = ConnectRPC::Bindings::GoogleProtobuf.new
212
+ handler = lambda do |request, context|
213
+ context.check!
214
+ context.response_metadata.add("x-worker", "ruby")
215
+ context.response_trailers.add("x-finished", "yes")
216
+ type.new(value: "Hello, #{request.value}!")
217
+ end
218
+ server = ConnectRPC::Server.new(
219
+ services: [service], binding: binding, handlers: { method.path => handler }
220
+ )
221
+ app = ConnectRPC::Rack.new(server: server) # Supply this to your Rack host.
222
+ ```
223
+
224
+ `ConnectRPC::Rack.new(downstream_app, server: server)` falls through for unrelated
225
+ services. An unknown method within a registered service returns `unimplemented`.
226
+ Rack routes `PATH_INFO`, so mounting under a prefix works. Handlers receive a
227
+ typed request and a fresh `Context` for each call. A custom
228
+ `invoker: ->(method, request, context) { ... }` can own execution instead.
229
+ Reusable handlers must synchronize any application state they share.
230
+
231
+ With that app hosted at a base URL, a client call is:
232
+
233
+ ```ruby
234
+ client = ConnectRPC::Client.new(
235
+ base_url: "http://127.0.0.1:8080", binding: binding, codec: :json
236
+ )
237
+ response = client.call(
238
+ method, type.new(value: "Ruby"), metadata: { "x-request" => ["example"] }, timeout: 2
239
+ )
240
+ puts response.message.value
241
+ p response.metadata.to_h
242
+ p response.trailers.to_h
243
+ ```
244
+
245
+ The default codec is `:proto`; `:json` uses the native protobuf JSON mapping,
246
+ including int64 strings, enum names, presence, and well-known types. Decoding
247
+ ignores unknown JSON fields. The Google binding handles scalar wrapper roots
248
+ through the native Any decoder because protobuf 4.36 cannot parse those roots
249
+ directly. This does not change Connect error details: each `ErrorDetail` has a
250
+ protobuf type name and binary bytes, with a base64 representation on the wire.
251
+
252
+ Bindings implement `encode(message, type:, codec:)` and
253
+ `decode(bytes, type:, codec:)`, raising `ConnectRPC::CodecError` for codec
254
+ failures. Transports implement `call(TransportRequest)` and return a
255
+ `TransportResponse`. Their fields and responsibilities are documented in
256
+ [design](docs/design.md). Neither seam requires Google or Rack types.
257
+
258
+ ## Unary gzip
259
+
260
+ Identity is the default. Configure `encoding: :gzip` on the client to compress
261
+ requests and advertise `gzip, identity` for responses. Configure it on the server
262
+ to accept gzip requests and negotiate gzip success responses in both codecs:
263
+
264
+ ```ruby
265
+ client = ConnectRPC::Client.new(base_url: "http://127.0.0.1:8080", binding: binding,
266
+ encoding: :gzip)
267
+ server = ConnectRPC::Server.new(services: [service], binding: binding,
268
+ handlers: { method.path => handler }, encoding: :gzip)
269
+ ```
270
+
271
+ Client `accept_encoding:` independently controls accepted responses. Its default
272
+ is derived from request encoding. An explicit nonempty array contains distinct
273
+ `:gzip`/`:identity` symbols; identity is always appended as the last fallback.
274
+ For example, `accept_encoding: [:gzip]` permits identity requests with gzip
275
+ responses; `encoding: :gzip, accept_encoding: [:identity]` permits gzip requests
276
+ with identity responses. The server selects a supported compression coding from
277
+ the ordered accept fields before identity. An absent field inherits the request
278
+ coding; an explicitly empty field selects identity. Unknown tokens and quality
279
+ values are ignored. Only one identity or gzip content coding is accepted.
280
+
281
+ Empty serialized messages selected for gzip produce valid nonempty gzip members.
282
+ Zero-length HTTP content is never inflated, after its coding header is validated.
283
+ Concatenated members are supported under one aggregate decoded limit; every
284
+ member's CRC, size and completion are validated. Truncation and trailing garbage
285
+ are rejected. Server errors remain identity; clients can decode compressed peer
286
+ errors, including typed details and metadata. Malformed compressed HTTP errors
287
+ use HTTP fallback, while malformed compressed success responses return internal.
288
+
289
+ ## Metadata and failures
290
+
291
+ Metadata keys are normalized to lower case and values are arrays of printable
292
+ ASCII header strings. Protocol and framing keys are reserved. Use
293
+ `metadata.add_binary("key-bin", bytes)` and `metadata.binary("key-bin")` for
294
+ binary values. Writers emit unpadded base64; readers accept valid padded and
295
+ unpadded base64. Only binary helpers split coalesced comma-separated values.
296
+ Other metadata values keep their received boundaries.
297
+ Incoming HTTP fields whose names or values cannot be represented as Connect
298
+ metadata are omitted, including the entire field if any repeated value is
299
+ invalid. Valid adjacent fields remain available in leading and trailing
300
+ metadata. Outbound names and values are always validated strictly.
301
+
302
+ Rack responses preserve arrays. Net::HTTP preserves repeated raw response
303
+ fields, but joins outgoing request arrays with `", "`. rackup/WEBrick also
304
+ coalesces incoming and response fields. For example, `['a', 'b']` may arrive as
305
+ `['a, b']`. Interop asserts these observed values; original outbound field
306
+ boundaries are not preserved.
307
+
308
+ Raise a deliberate `ConnectRPC::Error.new(:invalid_argument, "safe message",
309
+ details: [...], metadata: {...}, trailers: {...})` from a handler. Unexpected
310
+ application exceptions become `internal` with `"Internal error"`. Clients raise
311
+ `ConnectRPC::Error` with `code`, `details`, `metadata`, and `trailers`. Decode a
312
+ detail with `detail.decode(binding: binding, type: message_class)` after choosing
313
+ the class corresponding to `detail.type`.
314
+
315
+ ## Limits and deadlines
316
+
317
+ Serialized/decompressed request and response limits default to 4 MiB each.
318
+ Set `request_limit:` and `response_limit:` on either endpoint to positive integers.
319
+ Separate `request_wire_limit:` and `response_wire_limit:` cap encoded HTTP bytes
320
+ and default to their corresponding message limits. Identity must satisfy both
321
+ caps. Gzip can expand small messages, including empty-message framing; explicitly
322
+ increase the wire cap if that expansion is desired. Compression never silently
323
+ falls back to identity after exceeding a cap. `TransportRequest.response_limit`
324
+ remains the wire read cap for custom transports.
325
+
326
+ Input is bounded before inflation and binding decode, including unknown-length
327
+ Rack input and HTTP errors. Native gzip output is checked before each append,
328
+ with at most one 16 KiB output callback beyond the retained limit. This is not a
329
+ total allocation bound: wire and decoded buffers coexist, and native serialization
330
+ can allocate the whole message before its size check. Server identity errors use
331
+ the smaller response cap and safe replacement; if the replacement cannot fit,
332
+ they send an empty 429 and clients apply HTTP fallback. Header size limits and
333
+ HTTP input coalescing belong to the library and host.
334
+
335
+ Client `timeout:` accepts finite positive seconds, defaulting to 30. Explicit
336
+ `timeout: nil` sends no timeout header and creates no logical deadline or expiry
337
+ watchdog. Native TCP/TLS/read/write waits remain independently capped at
338
+ `9_999_999.999` seconds; a native timeout returns `unavailable`. Sustained progress
339
+ can outlast that per-operation cap. For finite values, one monotonic
340
+ budget starts before encoding and covers network I/O and checks around codec
341
+ work. NetHTTP uses one connection per call, closes it on every outcome, sends
342
+ the configured `Accept-Encoding`, ignores proxy environment variables, disables
343
+ retries and decompression, preserves TLS peer/hostname verification, and does
344
+ not follow redirects. A transport-owned watchdog closes its socket at expiry,
345
+ including during response-header trickle and blocked writes. Native DNS
346
+ resolution may outlast the budget; expiry is checked when control returns.
347
+ Codec work and request preparation are synchronous and checked before/after;
348
+ gzip work also checks each native input/output chunk and member boundary. They are
349
+ not interrupted. Dispatch rechecks expiry and refreshes the wire timeout after
350
+ connection setup; a closed socket cannot trigger an implicit reconnect. Native
351
+ operations are independently capped at `9_999_999.999` seconds, the Connect
352
+ wire maximum, to avoid platform overflow for very large budgets. A native
353
+ operation timeout before logical expiry returns `unavailable`; actual expiry
354
+ returns `deadline_exceeded`. The logical deadline stays unchanged, and the
355
+ watchdog rechecks it after each capped wait.
356
+
357
+ Explicit unary cancellation uses a permanent, shareable token:
358
+
359
+ ```ruby
360
+ source = ConnectRPC::CancellationSource.new
361
+ # Pass source.token to each call that should share cancellation.
362
+ response = client.call(method, message, timeout: nil, cancellation: source.token)
363
+ # Another thread or reactor task can call source.cancel while the call blocks.
364
+ ```
365
+
366
+ `source.cancel` returns true only for its first transition. A token exposes
367
+ `cancelled?`, `reason` (`:canceled` or nil), `check!`, and
368
+ `on_cancel { ... }`. Registration returns an object with `unregister`, which
369
+ returns true only when it prevents an invocation that has not started. It never
370
+ waits for a running listener. Listeners run in the cancelling thread, must be
371
+ short and nonblocking, and isolate `StandardError`. Unregistering releases the
372
+ callback; completing a call releases its internal registrations. Sources cannot
373
+ be reset, and a call never completes or changes another call's shared token.
374
+
375
+ Each call accepts one terminal outcome. An already-expired deadline takes
376
+ precedence while the call is open, followed by observed explicit cancellation.
377
+ Accepted cancellation stays canceled through later expiry. A committed response
378
+ or error stays final after later source cancellation. Source transition time is
379
+ not acceptance time: a slow earlier listener may delay acceptance until expiry.
380
+ Final decoding/error commit checks the source directly, even if notification is
381
+ delayed. Cancellation never substitutes a timeout header for `timeout: nil`.
382
+
383
+ NetHTTP closes only its call-owned socket to interrupt exposed TLS, writes,
384
+ headers and body reads. Native DNS/TCP before socket exposure remains
385
+ cooperative; cancellation is checked on return before any later dispatch.
386
+ The optional Async transport wakes its owner reactor and resets its stream.
387
+ Healthy multiplexed siblings survive; a stuck frame retains the documented
388
+ connection-wide cleanup fallback. Neither backend retries or replays a call.
389
+
390
+ Server contexts expose `deadline`, `cancelled?`, `cancellation_reason`
391
+ (`:canceled`, `:deadline_exceeded`, or nil), and `check!`. Handlers cooperate at
392
+ application boundaries. Low-level `Server#call(cancellation:)` and the Testing
393
+ transport accept explicit cancellation. Native HTTP/2 publishes peer
394
+ `RST_STREAM(CANCEL)` before input teardown; normal END_STREAM/half-close, other
395
+ reset codes, connection loss and host shutdown do not manufacture explicit
396
+ cancellation. Rack/Rails and Falcon HTTP/1 add no peer detection or token env
397
+ injection. Synchronous adapters cannot interrupt blocked application code.
398
+ See [design](docs/design.md) for ownership and transport limits.
399
+
400
+ CRuby >= 3.4 is the baseline; project verification targets Ruby 3.4.12 and
401
+ 4.0.9 on Linux x64. Local setup builds from source; CI uses official prebuilt
402
+ runtimes. See [development](docs/development.md) for the
403
+ commands, dependency policy, isolated installation and evidence scope. The
404
+ interop fixture runs 18 cases: six Go-to-Go reference baseline cases and twelve
405
+ Ruby-to-Go/Go-to-Ruby cases. Both codecs share typed success, deliberate error
406
+ and request-limit scenarios. This covers the advertised unary subset, not full
407
+ conformance. A separate gzip portion adds 40 cases with actual wire observations,
408
+ including asymmetric raw Go HTTP probes and documented reference-client quirks.
409
+
410
+ The pinned [official conformance harness](conformance/README.md) independently
411
+ executes 96 client and 90 server identity cases, including two safe GET cases
412
+ per direction. The disjoint gzip profile adds 88 client and 82 server cases.
413
+ All 184 client and 172 server cases must pass with zero expected failures.
414
+ The four caller-cancellation cases use their original 5 ms asynchronous timer,
415
+ 200 ms response request and no RPC deadline. The ledger requires actual request
416
+ writes, source cancellation, owned socket cleanup and the library's canceled
417
+ result. This is a bounded profile, not full Connect conformance.
418
+
419
+ ## Status and licensing
420
+
421
+ Original project work is licensed under the [MIT License](LICENSE), copyright
422
+ 2026 Jim Myhrberg. The core, Rack, Rails, Async and testing gem archives each include the
423
+ complete license. Third-party dependencies and imported fixtures retain their
424
+ own licenses and notices, including the separately licensed
425
+ [conformance schemas](conformance/README.md).
426
+
427
+ The gems pin `allowed_push_host` to `https://rubygems.org` and require MFA for
428
+ manual pushes. Releases are published only by the tag-triggered
429
+ [release workflow](docs/releasing.md), using RubyGems trusted publishing with
430
+ Sigstore attestations.
431
+
432
+ ## Optional standalone HTTP/2
433
+
434
+ The [connectrpc-async package](gems/connectrpc-async/README.md) provides explicit
435
+ `connectrpc/async` integration: `ConnectRPC::Async::Host` and a matching pooled
436
+ `Transport` for Connect unary and streaming protobuf/ProtoJSON over h2c and verified TLS `h2`.
437
+ It supports required client authentication, complete live TLS snapshot reloads
438
+ on the same listener, older-generation drain, and an authenticated client leaf
439
+ accessor scoped to the exact handler task. Applications own the Async reactor,
440
+ authorization and lifecycle. There is no HTTP/1 fallback or automatic replay. See the package guide for defaults, bounded cleanup and backend
441
+ header normalization. `mise run async:example` runs the generated localhost
442
+ example; `async:verify` checks the independent bundle, installed packages and
443
+ connect-go interoperability. Core require and core/Rails dependencies stay
444
+ independent.
445
+
446
+ The same package provides `HTTP1Transport.new(origin:, connection_limit: 4,
447
+ max_requests: 128, connect_timeout: 10)` for explicit Connect HTTP/1.1 unary
448
+ (including safe GET), server streaming and client streaming. Pass it through the
449
+ existing core/generated client's `transport:` option inside an application-owned
450
+ Async reactor. TLS/mTLS verifies peer and hostname, offers only `http/1.1`, and
451
+ accepts absent ALPN for legacy H1 servers. There is no gRPC, bidi, replay or
452
+ protocol fallback. See the [package guide](gems/connectrpc-async/README.md#explicit-http11-client)
453
+ for lease capacity, deadlines and upload failure limits. Ordinary-thread
454
+ persistent clients remain [issue #29](https://github.com/jimeh/connectrpc-ruby/issues/29).
455
+
456
+ See the optional [in-process testing package](gems/connectrpc-testing/README.md) for real encoded client/server round trips without sockets.
457
+
458
+ ## Optional Falcon hosting
459
+
460
+ The [connectrpc-falcon package](gems/connectrpc-falcon/README.md) serves the native
461
+ application in real Falcon process workers, with explicit Rack/Rails
462
+ fallback, h2/HTTP1 TLS selection and worker-local versioned TLS providers.
463
+ RPC compression and byte limits stay outside generic Falcon middleware.
464
+ WebSockets, ActionCable, upgrades, CONNECT and hijack are rejected on this
465
+ listener. `mise run falcon:example` exercises the normal service DSL;
466
+ `falcon:verify` checks its independent bundle, protocols, workers, packages and
467
+ pinned Go interoperability. Core, Rails and Async bundles remain independent.
468
+
469
+ ## RPC lifecycle instrumentation
470
+
471
+ Pass `instrumentation: ConnectRPC::Instrumentation.new(subscribers: [recorder])`
472
+ to a Client or Server for synchronous per-call observation. A subscriber's
473
+ `start(call)` returns an observer with optional `attach`, `detach(token)`,
474
+ `processing_finished(outcome)` and `finished(completion)` methods. Core owns the
475
+ application yield and isolates each StandardError from subscribers and the
476
+ optional `on_error:` reporter. Original exceptions are process-local trusted
477
+ subscriber data. Instrumentation is disabled by default and performs no lifecycle
478
+ allocation or instrumentation clock reads on that path.
479
+
480
+ The optional [connectrpc-otel package](gems/connectrpc-otel/README.md) turns this
481
+ lifecycle into real Connect CLIENT/SERVER traces using an application-owned
482
+ OpenTelemetry SDK. Core has no OpenTelemetry dependency. See
483
+ [design](docs/design.md#rpc-lifecycle-observation) for host ownership and
484
+ `mise run otel:example` for a runnable generated caller with real SDK exports.
485
+
486
+ ## Native Connect streaming
487
+
488
+ Async and Falcon native HTTP/2 support server, client and bidirectional streaming.
489
+ Falcon also serves Connect server and client streaming over HTTP/1.1 with both
490
+ codecs and identity/gzip over TCP/TLS/mTLS. Use the explicit
491
+ `ConnectRPC::Async::HTTP1Transport` for Ruby H1 unary, server streaming and
492
+ client streaming. The standalone Async Host stays H2-only. Early H1 responses finish
493
+ before bounded input discard. Retiring delivered responses use one cooperative
494
+ 500 ms total final-close budget from the actual final HTTP flush, including the
495
+ unchanged 100 ms / 256 KiB discard allowance. Admission remains held until
496
+ retirement and original processing join.
497
+ See the [streaming guide](docs/streaming.md) for scoped APIs, mixed generated
498
+ services, bounds and the one-second HTTP tail completion policy.
499
+
500
+ Explicit server gRPC-Web support is documented in the [Web guide](docs/grpc-web.md),
501
+ including native H1/H2 streaming, portable unary, CORS and the optional official browser harness.