connectrpc-falcon 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.
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+
5
+ module ConnectRPC
6
+ module Falcon
7
+ class RackGuard
8
+ class Unsupported < StandardError; end
9
+ private_constant :Unsupported
10
+
11
+ class Request < SimpleDelegator
12
+ def initialize(request, reject)
13
+ super(request)
14
+ @reject = reject
15
+ end
16
+
17
+ def hijack? = false
18
+ def hijack! = @reject.call
19
+ end
20
+ private_constant :Request
21
+
22
+ def initialize(app)
23
+ raise ArgumentError, "rack_app must be callable" unless app.respond_to?(:call)
24
+
25
+ @app = app
26
+ end
27
+
28
+ def call(env)
29
+ attempted = false
30
+ reject = lambda do
31
+ attempted = true
32
+ raise Unsupported, "Socket ownership transfer is unsupported"
33
+ end
34
+ env["rack.hijack?"] = false
35
+ env["rack.hijack"] = reject
36
+ env["protocol.http.request"] = Request.new(env["protocol.http.request"], reject)
37
+ response = @app.call(env)
38
+ status, headers, body = response
39
+ original = guarded_body(body)
40
+ if attempted || status == 101 || headers.keys.any? do |key|
41
+ %w[rack.hijack rack.protocol upgrade].include?(key.downcase)
42
+ end
43
+ return rejected
44
+ end
45
+
46
+ response = [status, headers, completing_body(env, status, headers, original)]
47
+ handed_off = true
48
+ response
49
+ rescue Unsupported
50
+ rejected
51
+ ensure
52
+ original&.close($ERROR_INFO) unless handed_off
53
+ end
54
+
55
+ private
56
+
57
+ def guarded_body(body)
58
+ if body.is_a?(::Protocol::HTTP::Body::Readable)
59
+ CloseOnce.new(body)
60
+ elsif body
61
+ RackBody.new(body)
62
+ end
63
+ end
64
+
65
+ def completing_body(env, status, headers, original)
66
+ # Original-body ownership is needed even without callbacks: upstream
67
+ # array conversion retains only the array's contents.
68
+ callbacks = env["rack.response_finished"] || []
69
+ callback = ::Protocol::Rack::Body.completion_callback(callbacks, env, status, headers)
70
+ env["rack.response_finished"] = []
71
+ CompletionBody.new(callback, original) do
72
+ ::Protocol::Rack::Body.wrap(env, status, headers.dup, original, env["protocol.http.request"].body)
73
+ end
74
+ end
75
+
76
+ def rejected
77
+ [501, { "content-type" => "text/plain" }, ["Socket ownership transfer is unsupported"]]
78
+ end
79
+ end
80
+ private_constant :RackGuard
81
+
82
+ class RackBody < SimpleDelegator
83
+ def close(*)
84
+ return if @closed
85
+
86
+ @closed = true
87
+ __getobj__.close if __getobj__.respond_to?(:close)
88
+ end
89
+ end
90
+ private_constant :RackBody
91
+
92
+ class CompletionBody < ::Protocol::HTTP::Body::Wrapper
93
+ def initialize(callback, original)
94
+ super(::Protocol::HTTP::Body::Buffered.wrap([]))
95
+ @callback = callback
96
+ @original = original
97
+ begin
98
+ @body = yield || @body
99
+ converted = true
100
+ ensure
101
+ close($ERROR_INFO) unless converted
102
+ end
103
+ end
104
+
105
+ def rewindable? = false
106
+ def rewind = false
107
+
108
+ # The Rack adapter closes a rejected tuple without passing its exception.
109
+ def close(error = $ERROR_INFO)
110
+ return if @closed
111
+
112
+ @closed = true
113
+ begin
114
+ @callback.call(error)
115
+ ensure
116
+ begin
117
+ super
118
+ ensure
119
+ @original&.close(error)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ private_constant :CompletionBody
125
+
126
+ class ListenerApplication
127
+ def initialize(application, scheme)
128
+ @application = application
129
+ @scheme = scheme
130
+ end
131
+
132
+ def publication?(request) = @application.send(:publication?, request)
133
+ def http1_streaming_route?(request) = @application.send(:http1_streaming_route?, request)
134
+ private :publication?, :http1_streaming_route?
135
+ def admission_response(request) = @application.admission_response(request)
136
+
137
+ def call(request, lifecycle: nil, publication: nil, resource: nil)
138
+ request.scheme ||= @scheme
139
+ if request.method == "CONNECT" || request.protocol || request.headers["upgrade"]
140
+ lifecycle&.host_rejected!
141
+ return ::Protocol::HTTP::Response[501, {}, nil]
142
+ end
143
+
144
+ response = if publication
145
+ publication.on_publish { |head| head.body = CloseOnce.new(head.body) if head&.body }
146
+ @application.call_with_publication(request, lifecycle: lifecycle, publication: publication,
147
+ resource: resource)
148
+ else
149
+ @application.call(request, lifecycle: lifecycle)
150
+ end
151
+ return if publication && response.nil?
152
+
153
+ if response.nil? || response.status == 101 || response.protocol
154
+ response&.close
155
+ return ::Protocol::HTTP::Response[501, {}, nil]
156
+ end
157
+ response.body = CloseOnce.new(response.body) if response.body
158
+ response
159
+ end
160
+ end
161
+ private_constant :ListenerApplication
162
+
163
+ # Upstream write ensure and cancellation can both close a body. Mark before
164
+ # entering callbacks so a blocked rack.response_finished cannot reenter.
165
+ class CloseOnce < ::Protocol::HTTP::Body::Wrapper
166
+ def close(error = nil)
167
+ return if @closed
168
+
169
+ @closed = true
170
+ super
171
+ end
172
+ end
173
+ private_constant :CloseOnce
174
+ end
175
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module Falcon
5
+ class Server < ::Falcon::Server
6
+ def initialize(server:, endpoint:, rack_app: nil, cache: false, tls: nil, tls_provider: nil,
7
+ utilization_registry: nil, max_connections: 128, max_requests: 128,
8
+ handshake_timeout: 10, idle_timeout: 60, refresh_interval: 60,
9
+ refresh_jitter: 0.1, refresh_timeout: 10, on_tls_error: nil)
10
+ raise ArgumentError, "cache must be true or false" unless [true, false].include?(cache)
11
+
12
+ configured = if endpoint.is_a?(::IO::Endpoint::BoundEndpoint)
13
+ endpoint.endpoint
14
+ else
15
+ endpoint
16
+ end
17
+ unless configured.is_a?(Endpoint) && (configured.scheme == "https") == (!tls.nil? || !tls_provider.nil?)
18
+ raise ArgumentError, "Listener scheme and TLS configuration must agree"
19
+ end
20
+
21
+ fallback = ::Falcon::Server.rack_middleware(RackGuard.new(rack_app), cache: cache) unless rack_app.nil?
22
+ application = ::ConnectRPC::Async::Application.new(server: server, fallback: fallback)
23
+ listener_application = ListenerApplication.new(application, configured.scheme)
24
+ @listener_application = listener_application
25
+ utilization_registry ||= ::Async::Utilization::Registry.new
26
+ @connection_total = utilization_registry.metric(:connections_total)
27
+ @connection_active = utilization_registry.metric(:connections_active)
28
+ @request_total = utilization_registry.metric(:requests_total)
29
+ @request_active = utilization_registry.metric(:requests_active)
30
+ @session = ::ConnectRPC::Async::ServerSession.new(
31
+ application: self, lifecycle_factory: application.method(:start_call),
32
+ tls: tls, tls_provider: tls_provider,
33
+ max_connections: max_connections, max_requests: max_requests,
34
+ handshake_timeout: handshake_timeout, idle_timeout: idle_timeout,
35
+ refresh_interval: refresh_interval, refresh_jitter: refresh_jitter,
36
+ refresh_timeout: refresh_timeout, on_tls_error: on_tls_error,
37
+ alpn_protocols: %w[h2 http/1.1], http1: HTTP1Resource.method(:new),
38
+ stop_accepting: method(:stop_accepting), on_request_rejected: -> { @request_total.increment }
39
+ )
40
+ # Preserve parent-bound descriptors while installing a worker-local
41
+ # accept wrapper through IO::Endpoint's supported wrapper option.
42
+ @accept_wrapper = AcceptWrapper.new(@session, on_reject: -> { @connection_total.increment })
43
+ endpoint = @owned_bound = endpoint.bound unless endpoint.is_a?(::IO::Endpoint::BoundEndpoint)
44
+ wrapped = endpoint.with(wrapper: @accept_wrapper)
45
+ super(listener_application, wrapped, protocol: configured.protocol, scheme: configured.scheme,
46
+ utilization_registry: utilization_registry)
47
+ rescue StandardError
48
+ @session&.abort
49
+ raise
50
+ end
51
+
52
+ def run
53
+ @session.start
54
+ @run_task = super
55
+ end
56
+
57
+ def accept(peer, _address, **)
58
+ @connection_total.increment
59
+ @session.schedule { @connection_active.track { peer.wait_closed } }
60
+ peer.serve
61
+ end
62
+
63
+ def close(grace: 5)
64
+ @session.close(grace: grace)
65
+ end
66
+
67
+ def abort = @session.abort
68
+
69
+ def publication?(request) = @listener_application.send(:publication?, request)
70
+ def http1_streaming_route?(request) = @listener_application.send(:http1_streaming_route?, request)
71
+ private :publication?, :http1_streaming_route?
72
+ def admission_response(request) = @listener_application.admission_response(request)
73
+
74
+ def call_with_publication(request, publication:, lifecycle: nil, resource: nil)
75
+ publication.on_publish do |response|
76
+ response.body = ::Falcon::Body::RequestFinished.new(response.body, @request_active) if response&.body
77
+ end
78
+ call_native(request, lifecycle: lifecycle, publication: publication, resource: resource)
79
+ end
80
+
81
+ def call(request, lifecycle: nil)
82
+ call_native(request, lifecycle: lifecycle)
83
+ end
84
+
85
+ def call_native(request, lifecycle: nil, publication: nil, resource: nil)
86
+ @request_total.increment
87
+ @request_active.increment
88
+ response = @listener_application.call(request, lifecycle: lifecycle, publication: publication,
89
+ resource: resource)
90
+ if response&.body && !publication
91
+ # Empty bodies can still own completion callbacks. Preserve their
92
+ # wire length while retaining the metric until close finishes.
93
+ response.body = ::Falcon::Body::RequestFinished.new(response.body, @request_active)
94
+ wrapped = true
95
+ end
96
+ response
97
+ ensure
98
+ retained = publication && (publication.published? || response&.body)
99
+ @request_active.decrement unless wrapped || retained
100
+ end
101
+
102
+ private :call_native
103
+
104
+ def reload_tls(**) = @session.reload_tls(**)
105
+ def refresh_tls = @session.refresh_tls
106
+ def drain_tls(**) = @session.drain_tls(**)
107
+ def tls_generation = @session.tls_generation
108
+ def tls_status = @session.tls_status
109
+ def peer_certificate = @session.peer_certificate
110
+
111
+ private
112
+
113
+ def stop_accepting
114
+ # Stop this worker's accept tasks after session validation. Accepted
115
+ # peers have an independent lifetime; parent-bound listeners stay open.
116
+ @accept_wrapper&.stop
117
+ @owned_bound&.close
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module Falcon
5
+ VERSION = "0.1.0" # x-release-please-version
6
+ end
7
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "connectrpc/async"
4
+ require_relative "falcon/version"
5
+ require "falcon/server"
6
+ require "falcon/environment/server"
7
+ require "async/http/endpoint"
8
+ require "io/endpoint/wrapper"
9
+ require "delegate"
10
+
11
+ module ConnectRPC
12
+ # Explicit optional listener. Falcon owns process supervision and accepts.
13
+ module Falcon
14
+ end
15
+ end
16
+
17
+ require_relative "falcon/endpoint"
18
+ require_relative "falcon/rack_guard"
19
+ require_relative "falcon/http1"
20
+ require_relative "falcon/server"
21
+ require_relative "falcon/environment"
@@ -0,0 +1,29 @@
1
+ # Explicit opt-in for Falcon 0.57.0 / async-service 0.25.0 service configuration.
2
+ # These are the documented ConnectRPC DSL forms, not general method_missing.
3
+ module Async
4
+ module Service
5
+ class Loader
6
+ def service: (?String?) { () [self: Environment::Builder] -> void } -> void
7
+ end
8
+ class Environment
9
+ class Builder < BasicObject
10
+ def include: (Module) -> void
11
+ def url: (String) -> void
12
+ | () { () [self: ConnectRPC::Falcon::Environment] -> String } -> void
13
+ def count: (Integer) -> void
14
+ | () { () [self: ConnectRPC::Falcon::Environment] -> Integer } -> void
15
+ def prepare!: () { (Object) [self: ConnectRPC::Falcon::Environment] -> void } -> void
16
+ def connectrpc_server: (ConnectRPC::Server) -> void
17
+ | () { () [self: ConnectRPC::Falcon::Environment] -> ConnectRPC::Server } -> void
18
+ def rack_app: ((ConnectRPC::Falcon::_RackFallback | ^(Hash[String, Object]) -> ConnectRPC::Falcon::rack_response)?) -> void
19
+ | () { () [self: ConnectRPC::Falcon::Environment] -> (ConnectRPC::Falcon::_RackFallback | ^(Hash[String, Object]) -> ConnectRPC::Falcon::rack_response)? } -> void
20
+ def tls: (ConnectRPC::Async::tls?) -> void
21
+ | () { () [self: ConnectRPC::Falcon::Environment] -> ConnectRPC::Async::tls? } -> void
22
+ def tls_provider: ((ConnectRPC::Async::_TLSProvider | ^() -> ConnectRPC::Async::tls_snapshot)?) -> void
23
+ | () { () [self: ConnectRPC::Falcon::Environment] -> (ConnectRPC::Async::_TLSProvider | ^() -> ConnectRPC::Async::tls_snapshot)? } -> void
24
+ def server_options: (ConnectRPC::Falcon::server_options) -> void
25
+ | () { () [self: ConnectRPC::Falcon::Environment] -> ConnectRPC::Falcon::server_options } -> void
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,66 @@
1
+ module ConnectRPC
2
+ module Falcon
3
+ VERSION: String
4
+ # Endpoint and task/socket objects expose only their consumed capabilities.
5
+ interface _BoundEndpoint
6
+ def endpoint: () -> Endpoint
7
+ def close: () -> void
8
+ end
9
+ class Endpoint
10
+ def self.parse: (String, ?Object?, **Object) -> Endpoint
11
+ def initialize: (URI::Generic, ?Object?, **Object) -> void
12
+ def scheme: () -> String
13
+ def bound: () -> _BoundEndpoint
14
+ def with: (**Object) -> Endpoint
15
+ end
16
+ # Keep this small Rack capability local: Falcon does not depend on the
17
+ # connectrpc-rack gem. Its structural shapes also accept Rack::_App values.
18
+ type rack_headers = Hash[String, String | Array[String]]
19
+ type rack_response = [Integer, rack_headers, _RackBody | _RackCallableBody]
20
+ type server_options = { ?cache: bool, ?max_connections: Integer, ?max_requests: Integer, ?handshake_timeout: Numeric, ?idle_timeout: Numeric?, ?refresh_interval: Numeric, ?refresh_jitter: Numeric, ?refresh_timeout: Numeric, ?on_tls_error: (Async::_TLSErrorHandler | ^(Async::tls_event) -> void)? }
21
+ interface _RackBody
22
+ def each: () { (String) -> void } -> void
23
+ end
24
+ interface _RackStream
25
+ def read: (?nil, ?String?) -> String
26
+ | (Integer?, ?String?) -> String?
27
+ def write: (String) -> Integer
28
+ # Protocol::HTTP returns a byte count; ordinary IO returns itself.
29
+ def <<: (String) -> (Integer | self)
30
+ def flush: () -> void
31
+ def close: () -> void
32
+ def close_read: () -> void
33
+ def close_write: () -> void
34
+ def closed?: () -> bool
35
+ end
36
+ interface _RackCallableBody
37
+ def call: (_RackStream) -> void
38
+ end
39
+ interface _RackFallback
40
+ def call: (Hash[String, Object]) -> rack_response
41
+ end
42
+ class Server
43
+ def initialize: (server: ConnectRPC::Server, endpoint: Endpoint | _BoundEndpoint, ?rack_app: (_RackFallback | ^(Hash[String, Object]) -> rack_response)?, ?cache: bool, ?tls: Async::tls?, ?tls_provider: (Async::_TLSProvider | ^() -> Async::tls_snapshot)?, ?utilization_registry: Object?, ?max_connections: Integer, ?max_requests: Integer, ?handshake_timeout: Numeric, ?idle_timeout: Numeric?, ?refresh_interval: Numeric, ?refresh_jitter: Numeric, ?refresh_timeout: Numeric, ?on_tls_error: (Async::_TLSErrorHandler | ^(Async::tls_event) -> void)?) -> void
44
+ def run: () -> Async::_Task
45
+ def call: (Async::_Request, ?lifecycle: Instrumentation::Handle?) -> Async::_Response?
46
+ def close: (?grace: Numeric) -> bool?
47
+ def abort: () -> Async::ServerSession
48
+ def reload_tls: (certificate_chain: Array[String], private_key: String, client_ca: Array[String]?) -> Integer
49
+ def refresh_tls: () -> Integer
50
+ def drain_tls: (?grace: Numeric) -> bool
51
+ def tls_generation: () -> Integer?
52
+ def tls_status: () -> Async::tls_status
53
+ def peer_certificate: () -> OpenSSL::X509::Certificate?
54
+ end
55
+ module Environment
56
+ def service_class: () -> Class
57
+ def endpoint: () -> Endpoint
58
+ def rack_app: () -> (_RackFallback | ^(Hash[String, Object]) -> rack_response)?
59
+ def tls: () -> Async::tls?
60
+ def tls_provider: () -> (Async::_TLSProvider | ^() -> Async::tls_snapshot)?
61
+ def server_options: () -> server_options
62
+ def connectrpc_server: () -> ConnectRPC::Server
63
+ def make_server: (Endpoint | _BoundEndpoint) -> Server
64
+ end
65
+ end
66
+ end
data/sig/manifest.yaml ADDED
@@ -0,0 +1,2 @@
1
+ dependencies:
2
+ - name: connectrpc-async
metadata ADDED
@@ -0,0 +1,151 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: connectrpc-falcon
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-async
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: falcon
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.57.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.57.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: protocol-rack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.23.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.23.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: async-service
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: 0.25.0
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 0.25.0
68
+ - !ruby/object:Gem::Dependency
69
+ name: async-container
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 0.38.0
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 0.38.0
82
+ - !ruby/object:Gem::Dependency
83
+ name: async-utilization
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 0.5.0
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: 0.5.0
96
+ - !ruby/object:Gem::Dependency
97
+ name: protocol-http1
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: 0.41.1
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: 0.41.1
110
+ executables: []
111
+ extensions: []
112
+ extra_rdoc_files: []
113
+ files:
114
+ - LICENSE
115
+ - README.md
116
+ - examples/falcon.rb
117
+ - lib/connectrpc/falcon.rb
118
+ - lib/connectrpc/falcon/completion_join.rb
119
+ - lib/connectrpc/falcon/endpoint.rb
120
+ - lib/connectrpc/falcon/environment.rb
121
+ - lib/connectrpc/falcon/http1.rb
122
+ - lib/connectrpc/falcon/rack_guard.rb
123
+ - lib/connectrpc/falcon/server.rb
124
+ - lib/connectrpc/falcon/version.rb
125
+ - rbs/async_service_0_25/service.rbs
126
+ - sig/connectrpc-falcon.rbs
127
+ - sig/manifest.yaml
128
+ homepage: https://github.com/jimeh/connectrpc-ruby
129
+ licenses:
130
+ - MIT
131
+ metadata:
132
+ allowed_push_host: https://rubygems.org
133
+ rubygems_mfa_required: 'true'
134
+ rdoc_options: []
135
+ require_paths:
136
+ - lib
137
+ required_ruby_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: '3.4'
142
+ required_rubygems_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ requirements: []
148
+ rubygems_version: 3.6.9
149
+ specification_version: 4
150
+ summary: Optional Falcon worker hosting for ConnectRPC unary
151
+ test_files: []