connectrpc-rack 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: 89157d4e979bf28657e56b42a59dfc8e904a24dbfb8be4df9c67ad1623604096
4
+ data.tar.gz: 7e41f28f18a84654ee4777013375f99e24fe1e8882d527142526af5ab22f5f09
5
+ SHA512:
6
+ metadata.gz: c4aa5acbebf4d24498dec78679c010d989c11907a680f07f0a285b9110555fd2f8bc353b3bffdf235d8810566c8f9e50da54211ef502577e0c836dca584d9e4b
7
+ data.tar.gz: '08032790182e68bb709634f1bca46d77217488e1622e47f9c1a9371a22ddb05bdd90b09d4e3a48fa210dbd67e97eb029f87ab1cf899de478ef662e4215d0000f'
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,96 @@
1
+ # connectrpc-rack
2
+
3
+ Optional, experimental Rack middleware for the unofficial Connect unary Ruby gem.
4
+ Requires Ruby >= 3.4, `connectrpc ~> 0.1.0` and `rack >= 3.2.7, < 4`.
5
+ This package uses the [MIT license](LICENSE), copyright 2026 Jim Myhrberg.
6
+ It is released with the whole [release train](../../docs/releasing.md).
7
+
8
+ See the [supported 0.x contract](../../docs/support.md) for the target support
9
+ profile, matched package versions, compatibility policy and adoption gates.
10
+
11
+ ## Installation and migration
12
+
13
+ Rack middleware now ships in this separate package. Applications that previously
14
+ used middleware from `connectrpc` must add this dependency to their Gemfile:
15
+
16
+ ```ruby
17
+ gem "connectrpc-rack", "~> 0.1.0"
18
+ ```
19
+
20
+ Keep `require "connectrpc/rack"`, `ConnectRPC::Rack.new(server: server)` and
21
+ `call(env)` unchanged. The entry point loads core normally. Core installed alone
22
+ has no runtime dependencies, loads no Rack, and has no `connectrpc/rack` file.
23
+ This changes installation; Rack already required an explicit optional require.
24
+
25
+ ## Usage
26
+
27
+ ```ruby
28
+ require "connectrpc/rack"
29
+
30
+ # Build a host-neutral ConnectRPC::Server with your binding and handlers.
31
+ app = ConnectRPC::Rack.new(server: server)
32
+ # Or preserve an existing application's unrelated routes:
33
+ app = ConnectRPC::Rack.new(downstream_app, server: server)
34
+ ```
35
+
36
+ Supply the app to your Rack host. This package does not start a production host.
37
+ It translates `PATH_INFO`, raw `QUERY_STRING`, request headers and `rack.input` into core Server
38
+ calls and returns Rack status, headers and body. Mount prefixes use `SCRIPT_NAME`;
39
+ registered service paths belong to ConnectRPC, including unknown methods.
40
+ Unrelated paths call the downstream app, or return 404 when none was supplied.
41
+ Repeated response metadata and `trailer-*` fields remain Rack 3 arrays. Rack env
42
+ normalization loses the distinction between underscore and hyphen request
43
+ metadata names; native metadata acceptance is unchanged.
44
+
45
+ The core Server owns unary proto/JSON negotiation, identity/opt-in gzip, bounded
46
+ input/output, typed errors and cooperative deadlines. Synchronous Rack cannot
47
+ interrupt blocked reads or arbitrary handlers or reliably detect disconnects.
48
+ See [design](../../docs/design.md), [examples](../../examples/unary.rb) and the
49
+ [optional Rails adapter](../connectrpc-on-rails/README.md) for native controller execution.
50
+
51
+ ## Development
52
+
53
+ The independent frozen development bundle reuses existing exact registry pins.
54
+ Run from the repository root:
55
+
56
+ ```sh
57
+ mise run rack:setup
58
+ mise run rack:test
59
+ mise run rack:check
60
+ mise run rack:package
61
+ mise run verify
62
+ mise run rack:verify
63
+ ```
64
+
65
+ Focused specs accept selectors, for example
66
+ `mise run rack:test -- spec/rack_spec.rb`. Bundles, Bundler state, lint cache and
67
+ archives live under `vendor/rack`; coverage is `coverage/rack/<Ruby>/` and starts
68
+ before the middleware require. `rack:package` builds its own core/Rack archives,
69
+ checks exact manifests/dependencies/push hosts, installs core alone and
70
+ then resolves the Rack package offline in a fresh external GEM_HOME. Real Google
71
+ proto/JSON identity/gzip calls, safe errors/details and metadata run under
72
+ Rack::Lint over ephemeral localhost NetHTTP, using installed sources only.
73
+ CI runs Rack specs/package checks on Ruby 3.4.12 and 4.0.9. Final local handoff
74
+ requires both `verify` and `rack:verify`; root verification composition is unchanged.
75
+
76
+ ## RPC observation
77
+
78
+ Configure `instrumentation:` on the core Client/Server to enable the fixed
79
+ neutral per-call lifecycle or the optional [OpenTelemetry adapter](../connectrpc-otel/README.md).
80
+ A supplied `rack.response_finished` extension takes precedence over body close.
81
+ Without it the observation is only `body_closed`, with no delivery promise.
82
+ Consumers must close bodies or invoke promised callbacks.
83
+
84
+ ## Static signatures
85
+
86
+ This package owns `sig/` and its discovery manifest. Add
87
+ `library "connectrpc-rack"` to a strict Steep target with this gem installed.
88
+ See the [RBS guide](../../docs/rbs.md) for supported APIs, extension contracts,
89
+ partial records, runtime validation and framework-signature limits.
90
+ `mise run rbs:check` checks application callers for this package; strict core
91
+ source checking is a separate target from backend implementation checking.
92
+
93
+ ## gRPC-Web
94
+
95
+ Explicitly enabled binary/text unary gRPC-Web runs through this middleware, including the rackup/WEBrick fixture. Streaming is rejected before input. Configure core CORS or keep it nil when outer middleware owns the mount.
96
+ See the [Web guide](../../docs/grpc-web.md) for protocol, CORS, bounds and completion rules.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ class Rack
5
+ VERSION = "0.1.0" # x-release-please-version
6
+ end
7
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "connectrpc"
4
+ require_relative "rack/version"
5
+
6
+ begin
7
+ gem "rack", ">= 3.2.7", "< 4"
8
+ require "rack"
9
+ rescue LoadError => e
10
+ raise LoadError, "ConnectRPC Rack integration requires rack >= 3.2.7, < 4: #{e.message}"
11
+ end
12
+
13
+ module ConnectRPC
14
+ class Rack
15
+ def initialize(app = nil, server:)
16
+ @app = app || ->(_env) { [404, { "content-type" => "text/plain" }, ["Not found"]] }
17
+ @server = server
18
+ end
19
+
20
+ def call(env) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
21
+ transferred = false
22
+ error = nil
23
+ path = env.fetch("PATH_INFO", "")
24
+ return @app.call(env) unless @server.handles?(path)
25
+
26
+ headers = request_headers(env)
27
+ early = @server.cors_response(path: path, http_method: env.fetch("REQUEST_METHOD"), headers: headers)
28
+ return [early.status, early.headers, [early.body]] if early
29
+
30
+ lifecycle = @server.start_call(path: path, headers: headers, http_method: env.fetch("REQUEST_METHOD"))
31
+ response = @server.call(path: path, http_method: env.fetch("REQUEST_METHOD"), headers: headers,
32
+ input: env["rack.input"], query: env.fetch("QUERY_STRING", ""), lifecycle: lifecycle)
33
+ body = [response.body]
34
+ if lifecycle
35
+ callbacks = env["rack.response_finished"]
36
+ if callbacks
37
+ callbacks << lambda do |_env, status, _headers, error|
38
+ lifecycle.finish(delivery: error ? :aborted : :response_finished, error: error, http_status: status)
39
+ end
40
+ else
41
+ body = Body.new(body, lifecycle)
42
+ end
43
+ end
44
+ result = [response.status, response.headers, body]
45
+ transferred = true
46
+ result
47
+ rescue Exception => e # rubocop:disable Lint/RescueException -- observe cancellation, then preserve it
48
+ error = e
49
+ raise
50
+ ensure
51
+ lifecycle&.finish(delivery: :aborted, error: error) unless transferred
52
+ end
53
+
54
+ class Body
55
+ def initialize(body, lifecycle)
56
+ @body = body
57
+ @lifecycle = lifecycle
58
+ end
59
+
60
+ def each(&) = @body.each(&)
61
+
62
+ def close
63
+ @lifecycle.finish(delivery: :body_closed)
64
+ end
65
+
66
+ def discard(error)
67
+ @lifecycle.finish(delivery: :aborted, error: error)
68
+ end
69
+ end
70
+ private_constant :Body
71
+
72
+ private
73
+
74
+ # Rails has not accepted ownership until its final header/body merge returns.
75
+ # Only our body owns disposal here; response_finished remains host-owned.
76
+ def transfer_response(env) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
77
+ transferred = false
78
+ error = nil
79
+ result = call(env)
80
+ status, headers, body = result
81
+ response = TransportResponse.new(status: status, headers: headers, body: "")
82
+ decorated = @server.cors_response(path: env.fetch("PATH_INFO"), http_method: env.fetch("REQUEST_METHOD"),
83
+ headers: request_headers(env), response: response)
84
+ value = yield result, !decorated.equal?(response)
85
+ transferred = true
86
+ value
87
+ rescue Exception => e # rubocop:disable Lint/RescueException -- preserve the original transfer failure
88
+ error = e
89
+ raise
90
+ ensure
91
+ body.discard(error) if !transferred && body.is_a?(Body)
92
+ end
93
+
94
+ def request_headers(env)
95
+ env.each_with_object({}) do |(key, value), result|
96
+ if key.start_with?("HTTP_")
97
+ result[key.delete_prefix("HTTP_").tr("_", "-").downcase] = [value]
98
+ elsif %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
99
+ result[key.tr("_", "-").downcase] = [value]
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,31 @@
1
+ module ConnectRPC
2
+ class Rack
3
+ VERSION: String
4
+ type env = Hash[String, Object]
5
+ type response_headers = Hash[String, String | Array[String]]
6
+ type response = [Integer, response_headers, _Body | _CallableBody]
7
+ interface _Body
8
+ def each: () { (String) -> void } -> void
9
+ end
10
+ interface _Stream
11
+ def read: (?nil, ?String?) -> String
12
+ | (Integer?, ?String?) -> String?
13
+ def write: (String) -> Integer
14
+ # Protocol::HTTP returns a byte count; ordinary IO returns itself.
15
+ def <<: (String) -> (Integer | self)
16
+ def flush: () -> void
17
+ def close: () -> void
18
+ def close_read: () -> void
19
+ def close_write: () -> void
20
+ def closed?: () -> bool
21
+ end
22
+ interface _CallableBody
23
+ def call: (_Stream) -> void
24
+ end
25
+ interface _App
26
+ def call: (env) -> response
27
+ end
28
+ def initialize: (?(_App | ^(env) -> response)?, server: Server) -> void
29
+ def call: (env) -> response
30
+ end
31
+ end
data/sig/manifest.yaml ADDED
@@ -0,0 +1,2 @@
1
+ dependencies:
2
+ - name: connectrpc
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: connectrpc-rack
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - jimeh
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-11-15 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: connectrpc
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: rack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 3.2.7
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '4'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 3.2.7
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '4'
46
+ description: Optional, experimental Rack integration for the unofficial ConnectRPC
47
+ Ruby gem.
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - LICENSE
53
+ - README.md
54
+ - lib/connectrpc/rack.rb
55
+ - lib/connectrpc/rack/version.rb
56
+ - sig/connectrpc-rack.rbs
57
+ - sig/manifest.yaml
58
+ homepage: https://github.com/jimeh/connectrpc-ruby
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ allowed_push_host: https://rubygems.org
63
+ rubygems_mfa_required: 'true'
64
+ source_code_uri: https://github.com/jimeh/connectrpc-ruby
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '3.4'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.6.9
80
+ specification_version: 4
81
+ summary: Rack middleware for Connect unary RPCs
82
+ test_files: []