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 +7 -0
- data/LICENSE +21 -0
- data/README.md +96 -0
- data/lib/connectrpc/rack/version.rb +7 -0
- data/lib/connectrpc/rack.rb +104 -0
- data/sig/connectrpc-rack.rbs +31 -0
- data/sig/manifest.yaml +2 -0
- metadata +82 -0
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,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
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: []
|