connectrpc-async 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 +542 -0
- data/lib/connectrpc/async/application.rb +173 -0
- data/lib/connectrpc/async/completion_join.rb +249 -0
- data/lib/connectrpc/async/connection.rb +711 -0
- data/lib/connectrpc/async/connection_manager.rb +358 -0
- data/lib/connectrpc/async/connection_policy.rb +60 -0
- data/lib/connectrpc/async/host.rb +171 -0
- data/lib/connectrpc/async/http1_connection.rb +144 -0
- data/lib/connectrpc/async/http1_exchange.rb +27 -0
- data/lib/connectrpc/async/http1_stream.rb +76 -0
- data/lib/connectrpc/async/http1_transport.rb +642 -0
- data/lib/connectrpc/async/ingress.rb +100 -0
- data/lib/connectrpc/async/internal.rb +270 -0
- data/lib/connectrpc/async/server_session.rb +457 -0
- data/lib/connectrpc/async/stream_exchange.rb +324 -0
- data/lib/connectrpc/async/tls.rb +124 -0
- data/lib/connectrpc/async/tls_configuration.rb +309 -0
- data/lib/connectrpc/async/transport.rb +488 -0
- data/lib/connectrpc/async/version.rb +7 -0
- data/lib/connectrpc/async.rb +39 -0
- data/sig/connectrpc-async.rbs +165 -0
- data/sig/manifest.yaml +4 -0
- metadata +202 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Async
|
|
5
|
+
module Internal
|
|
6
|
+
# One queued fragment and the output task's one in-flight fragment. Queue
|
|
7
|
+
# ownership transfers before waking the producer; no wire-flush promise.
|
|
8
|
+
class OutputBody < ::Protocol::HTTP::Body::Readable
|
|
9
|
+
attr_reader :queued_bytes, :maximum_queued_bytes
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
super
|
|
13
|
+
@changed = ::Async::Condition.new
|
|
14
|
+
@fragment = nil
|
|
15
|
+
@queued_bytes = @maximum_queued_bytes = 0
|
|
16
|
+
@ended = @closed = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(bytes, timeout: nil)
|
|
20
|
+
raise ArgumentError, "Output fragments must fit one frame" if bytes.bytesize > 16_384
|
|
21
|
+
|
|
22
|
+
expires = Internal.now + timeout if timeout
|
|
23
|
+
while @fragment && !@closed
|
|
24
|
+
if expires
|
|
25
|
+
waiter = ::Async::Task.current.async(finished: false) { @changed.wait }
|
|
26
|
+
begin
|
|
27
|
+
Internal.wait(waiter, expires)
|
|
28
|
+
ensure
|
|
29
|
+
Internal.cancel(waiter)
|
|
30
|
+
Internal.join(waiter)
|
|
31
|
+
end
|
|
32
|
+
else
|
|
33
|
+
@changed.wait
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
raise(@error || IOError.new("Output is closed")) if @closed || @ended
|
|
37
|
+
|
|
38
|
+
@fragment = bytes.dup.freeze
|
|
39
|
+
@queued_bytes = bytes.bytesize
|
|
40
|
+
@maximum_queued_bytes = [@maximum_queued_bytes, @queued_bytes].max
|
|
41
|
+
@changed.signal
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def read
|
|
46
|
+
@changed.wait until @fragment || @ended || @closed
|
|
47
|
+
raise @error if @error
|
|
48
|
+
|
|
49
|
+
fragment = @fragment
|
|
50
|
+
@fragment = nil
|
|
51
|
+
@queued_bytes = 0
|
|
52
|
+
@changed.signal
|
|
53
|
+
fragment
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def close_write
|
|
57
|
+
@ended = true
|
|
58
|
+
@changed.signal
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def close(error = nil)
|
|
63
|
+
return if @closed
|
|
64
|
+
|
|
65
|
+
@closed = true
|
|
66
|
+
@error = error
|
|
67
|
+
@fragment = nil
|
|
68
|
+
@queued_bytes = 0
|
|
69
|
+
@changed.signal
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Publication and processing completion are independent single-assignment
|
|
75
|
+
# events. The processing task owns both, including exceptional cleanup.
|
|
76
|
+
class Publication
|
|
77
|
+
def initialize
|
|
78
|
+
@head = ::Async::Promise.new
|
|
79
|
+
@completion = ::Async::Promise.new
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def publish(response)
|
|
83
|
+
raise LifecycleError, "Response head already published" if @published
|
|
84
|
+
|
|
85
|
+
@published = true
|
|
86
|
+
@transform&.call(response)
|
|
87
|
+
@body = response&.body
|
|
88
|
+
response.body = CompletionBody.new(@body, self) if response
|
|
89
|
+
@head.resolve([:response, response])
|
|
90
|
+
@head_ready = true
|
|
91
|
+
nil
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
@body = response&.body
|
|
94
|
+
complete(e)
|
|
95
|
+
raise
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def complete(error = nil)
|
|
99
|
+
return if @completed
|
|
100
|
+
|
|
101
|
+
@completed = true
|
|
102
|
+
begin
|
|
103
|
+
@body&.close(error) if error
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
error ||= e
|
|
106
|
+
ensure
|
|
107
|
+
unless @head_ready
|
|
108
|
+
@head_ready = true
|
|
109
|
+
@head.resolve([:error, error || IOError.new("Processing ended without a response")])
|
|
110
|
+
end
|
|
111
|
+
@completion.resolve(error)
|
|
112
|
+
end
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def on_publish(&transform)
|
|
117
|
+
raise LifecycleError, "Response head already published" if @published
|
|
118
|
+
|
|
119
|
+
previous = @transform
|
|
120
|
+
@transform = lambda { |response|
|
|
121
|
+
previous&.call(response)
|
|
122
|
+
transform.call(response)
|
|
123
|
+
}
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def published? = @published
|
|
128
|
+
|
|
129
|
+
def response
|
|
130
|
+
tag, value = @head.wait
|
|
131
|
+
raise value if tag == :error
|
|
132
|
+
|
|
133
|
+
value
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def wait
|
|
137
|
+
error = @completion.wait
|
|
138
|
+
raise error if error
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
class CompletionBody < ::Protocol::HTTP::Body::Wrapper
|
|
143
|
+
def initialize(body, publication)
|
|
144
|
+
super(body || ::Protocol::HTTP::Body::Buffered.wrap([]))
|
|
145
|
+
@publication = publication
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Even an empty body must retain this completion boundary.
|
|
149
|
+
def empty? = false
|
|
150
|
+
def length = nil
|
|
151
|
+
|
|
152
|
+
def read
|
|
153
|
+
bytes = super
|
|
154
|
+
@publication.wait unless bytes
|
|
155
|
+
bytes
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
class ServerOutput
|
|
160
|
+
def initialize(body)
|
|
161
|
+
@body = body
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def write(bytes, timeout: nil) = @body.write(bytes, timeout: timeout)
|
|
165
|
+
def abort = @body.close(IOError.new("Processing abandoned"))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
class GRPCInput < Input
|
|
169
|
+
def read(length, timeout: nil)
|
|
170
|
+
super
|
|
171
|
+
rescue ::Async::TimeoutError
|
|
172
|
+
raise Error.new(:unavailable, "gRPC input operation expired"), cause: nil
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
class GRPCServerOutput < ServerOutput
|
|
177
|
+
attr_writer :headers
|
|
178
|
+
|
|
179
|
+
def initialize(body, _trailers)
|
|
180
|
+
super(body)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def write(bytes, timeout: nil)
|
|
184
|
+
super
|
|
185
|
+
rescue ::Async::TimeoutError
|
|
186
|
+
raise Error.new(:unavailable, "gRPC output operation expired"), cause: nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def finish(headers:)
|
|
190
|
+
GRPCProtocol.check_fields!(headers)
|
|
191
|
+
headers.each { |key, values| values.each { |value| @headers.add(key, value, trailer: true) } }
|
|
192
|
+
@body.close_write
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# ALL terminal bytes wait for the original processor, including an outer
|
|
198
|
+
# application's ensure. The consumer only slices already prepared bytes.
|
|
199
|
+
class GRPCWebBody < OutputBody
|
|
200
|
+
def initialize(publication)
|
|
201
|
+
super()
|
|
202
|
+
@publication = publication
|
|
203
|
+
@terminal_offset = 0
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def stage_terminal(bytes)
|
|
207
|
+
@terminal = bytes.dup.freeze
|
|
208
|
+
close_write
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def read
|
|
213
|
+
bytes = super
|
|
214
|
+
return bytes if bytes
|
|
215
|
+
|
|
216
|
+
@publication.wait
|
|
217
|
+
return unless @terminal && @terminal_offset < @terminal.bytesize
|
|
218
|
+
|
|
219
|
+
bytes = @terminal.byteslice(@terminal_offset, 16_384)
|
|
220
|
+
@terminal_offset += bytes.bytesize
|
|
221
|
+
bytes
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def close(error = nil)
|
|
225
|
+
@terminal = nil
|
|
226
|
+
super
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
class GRPCWebServerOutput < ServerOutput
|
|
231
|
+
def finish(terminal:)
|
|
232
|
+
@body.stage_terminal(terminal)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
class StreamExchange
|
|
237
|
+
def initialize(transport, operation, body)
|
|
238
|
+
@transport = transport
|
|
239
|
+
@operation = operation
|
|
240
|
+
@body = body
|
|
241
|
+
@response = operation.fetch(:response)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def response_head(timeout: nil)
|
|
245
|
+
@response_head ||= perform(timeout: timeout) do
|
|
246
|
+
@operation.fetch(:connection).read_response(@response)
|
|
247
|
+
@input = Input.new(@response.body)
|
|
248
|
+
StreamHead.new(status: @response.status, headers: Internal.headers(@response))
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def read(length, timeout: nil)
|
|
253
|
+
response_head(timeout: timeout)
|
|
254
|
+
perform(timeout: timeout) { @input.read(length) }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def write(bytes)
|
|
258
|
+
perform { @body.write(bytes) }
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def close_send
|
|
263
|
+
perform { @body.close_write }
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def finish(timeout: nil)
|
|
268
|
+
perform(timeout: timeout) { @response.stream.output_task&.wait }
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def abort
|
|
273
|
+
@body.close(IOError.new("Stream abandoned"))
|
|
274
|
+
nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def perform(timeout: nil, &)
|
|
280
|
+
@transport.send(:stream_operation, @operation, timeout: timeout, &)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
class GRPCStreamExchange < StreamExchange
|
|
285
|
+
def response_head(timeout: nil)
|
|
286
|
+
@response_head ||= perform(timeout: timeout) do
|
|
287
|
+
@operation.fetch(:connection).read_response(@response)
|
|
288
|
+
@input = Input.new(@response.body)
|
|
289
|
+
stream = @response.stream
|
|
290
|
+
raise stream.grpc_error if stream.grpc_error
|
|
291
|
+
|
|
292
|
+
GRPCHead.new(status: @response.status, headers: stream.grpc_initial_headers,
|
|
293
|
+
end_stream: stream.grpc_initial_end_stream)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def read(length, timeout: nil)
|
|
298
|
+
bytes = super
|
|
299
|
+
@read_eof = true if bytes.nil? || bytes.empty?
|
|
300
|
+
raise @response.stream.grpc_error if @response.stream.grpc_error
|
|
301
|
+
|
|
302
|
+
bytes
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def response_trailers
|
|
306
|
+
raise StreamStateError, "Trailers require response EOF" unless @read_eof
|
|
307
|
+
|
|
308
|
+
@response.stream.grpc_trailers
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def finish(timeout: nil)
|
|
312
|
+
super
|
|
313
|
+
stream = @response.stream
|
|
314
|
+
raise stream.grpc_error if stream.grpc_error
|
|
315
|
+
unless stream.grpc_initial_end_stream || stream.grpc_trailers
|
|
316
|
+
raise Error.new(:internal, "Missing final gRPC HEADERS"), cause: nil
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
nil
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module ConnectRPC
|
|
7
|
+
module Async
|
|
8
|
+
module Internal
|
|
9
|
+
class TLS
|
|
10
|
+
CERTIFICATE = %r{\A-----BEGIN CERTIFICATE-----\s+[A-Za-z0-9+/=\s]+-----END CERTIFICATE-----\z}
|
|
11
|
+
PRIVATE_KEY = %r{
|
|
12
|
+
\A-----BEGIN\ (RSA\ PRIVATE\ KEY|EC\ PRIVATE\ KEY|PRIVATE\ KEY)-----
|
|
13
|
+
\s+[A-Za-z0-9+/=\s]+-----END\ \1-----\z
|
|
14
|
+
}x
|
|
15
|
+
|
|
16
|
+
def self.certificate(pem)
|
|
17
|
+
raise ArgumentError unless pem.is_a?(String) && CERTIFICATE.match?(pem.strip)
|
|
18
|
+
|
|
19
|
+
certificates = OpenSSL::X509::Certificate.load(pem.dup)
|
|
20
|
+
raise ArgumentError unless certificates.size == 1
|
|
21
|
+
|
|
22
|
+
certificate = certificates.first
|
|
23
|
+
raise ArgumentError unless certificate.not_before <= Time.now && certificate.not_after > Time.now
|
|
24
|
+
|
|
25
|
+
certificate
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.identity(chain, pem)
|
|
29
|
+
raise ArgumentError unless chain.is_a?(Array) && !chain.empty?
|
|
30
|
+
raise ArgumentError unless pem.is_a?(String) && PRIVATE_KEY.match?(pem.strip)
|
|
31
|
+
|
|
32
|
+
certificates = chain.map { |element| certificate(element) }
|
|
33
|
+
key = OpenSSL::PKey.read(pem.dup) { nil }
|
|
34
|
+
digest = %w[ED25519 ED448].include?(key.oid) ? nil : "SHA256"
|
|
35
|
+
challenge = SecureRandom.random_bytes(32)
|
|
36
|
+
signature = key.sign(digest, challenge)
|
|
37
|
+
raise ArgumentError unless certificates.first.public_key.verify(digest, signature, challenge)
|
|
38
|
+
|
|
39
|
+
certificates.each_cons(2) do |child, issuer|
|
|
40
|
+
raise ArgumentError unless child.issuer == issuer.subject && child.verify(issuer.public_key)
|
|
41
|
+
end
|
|
42
|
+
[certificates, key]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.configure(context)
|
|
46
|
+
context.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
47
|
+
context.options |= OpenSSL::SSL::OP_NO_TICKET
|
|
48
|
+
context.session_cache_mode = OpenSSL::SSL::SSLContext::SESSION_CACHE_OFF
|
|
49
|
+
context.session_id_context = SecureRandom.random_bytes(16)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.server(certificate_chain:, private_key:, client_ca:, alpn_protocols: ["h2"])
|
|
53
|
+
certificates, key = identity(certificate_chain, private_key)
|
|
54
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
55
|
+
configure(context)
|
|
56
|
+
context.add_certificate(certificates.first, key, certificates.drop(1))
|
|
57
|
+
unless client_ca.nil?
|
|
58
|
+
raise ArgumentError unless client_ca.is_a?(Array) && !client_ca.empty?
|
|
59
|
+
|
|
60
|
+
store = OpenSSL::X509::Store.new
|
|
61
|
+
anchors = client_ca.map { |pem| certificate(pem) }
|
|
62
|
+
anchors.uniq(&:to_der).each { |anchor| store.add_cert(anchor) }
|
|
63
|
+
store.flags = OpenSSL::X509::V_FLAG_PARTIAL_CHAIN
|
|
64
|
+
context.cert_store = store
|
|
65
|
+
context.verify_mode = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
|
|
66
|
+
end
|
|
67
|
+
context.alpn_select_cb = lambda do |protocols|
|
|
68
|
+
selected = alpn_protocols.find { |protocol| protocols.include?(protocol) }
|
|
69
|
+
raise OpenSSL::SSL::SSLError, "Unsupported ALPN" unless selected
|
|
70
|
+
|
|
71
|
+
selected
|
|
72
|
+
end
|
|
73
|
+
context.setup
|
|
74
|
+
trust = anchors.map(&:to_der).uniq.sort if anchors
|
|
75
|
+
canonical = [certificates.map(&:to_der), key.public_to_der, trust]
|
|
76
|
+
new(context, !client_ca.nil?, fingerprint: Digest::SHA256.digest(Marshal.dump(canonical)).freeze,
|
|
77
|
+
expires_at: certificates.map(&:not_after).min.freeze)
|
|
78
|
+
rescue StandardError
|
|
79
|
+
raise ArgumentError, "Invalid TLS snapshot", cause: nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.client(ca_file:, certificate_chain:, private_key:, alpn_protocols: ["h2"])
|
|
83
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
84
|
+
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER, verify_hostname: true)
|
|
85
|
+
configure(context)
|
|
86
|
+
store = OpenSSL::X509::Store.new
|
|
87
|
+
store.set_default_paths
|
|
88
|
+
store.add_file(ca_file) if ca_file
|
|
89
|
+
context.cert_store = store
|
|
90
|
+
if certificate_chain || private_key
|
|
91
|
+
certificates, key = identity(certificate_chain, private_key)
|
|
92
|
+
context.add_certificate(certificates.first, key, certificates.drop(1))
|
|
93
|
+
end
|
|
94
|
+
context.alpn_protocols = alpn_protocols
|
|
95
|
+
context.setup
|
|
96
|
+
new(context, false)
|
|
97
|
+
rescue StandardError
|
|
98
|
+
raise ArgumentError, "Invalid TLS client configuration", cause: nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def initialize(context, authenticated, fingerprint: nil, expires_at: nil)
|
|
102
|
+
@context = context
|
|
103
|
+
@authenticated = authenticated
|
|
104
|
+
@fingerprint = fingerprint
|
|
105
|
+
@expires_at = expires_at
|
|
106
|
+
freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
attr_reader :fingerprint
|
|
110
|
+
|
|
111
|
+
def check_validity!
|
|
112
|
+
raise ArgumentError, "Expired TLS snapshot", cause: nil if @expires_at && Time.now >= @expires_at
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def socket(raw)
|
|
116
|
+
OpenSSL::SSL::SSLSocket.new(raw, @context).tap { |socket| socket.sync_close = true }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def authenticated? = @authenticated
|
|
120
|
+
def inspect = "#<#{self.class} private TLS snapshot>"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|