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
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module ConnectRPC
6
+ # One immutable policy per mount. Authentication, CSRF and outer middleware
7
+ # remain application-owned.
8
+ class GRPCWebCORS
9
+ BASE_REQUEST = %w[content-type x-grpc-web x-user-agent grpc-timeout accept].freeze
10
+ BASE_EXPOSE = %w[grpc-status grpc-message grpc-status-details-bin].freeze
11
+ TOKEN = /\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/
12
+ private_constant :BASE_REQUEST, :BASE_EXPOSE, :TOKEN
13
+
14
+ attr_reader :origins, :credentials, :request_headers, :expose_headers
15
+
16
+ def initialize(origins:, credentials: false, request_headers: [], expose_headers: []) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
17
+ unless [true, false].include?(credentials) && origins.is_a?(Array) && origins.size <= 32 &&
18
+ origins.all? { |origin| valid_origin?(origin) } && origins.uniq.size == origins.size
19
+ raise ArgumentError, "CORS requires distinct exact HTTP(S) origins and boolean credentials"
20
+ end
21
+
22
+ @origins = origins.map { |origin| origin.dup.freeze }.freeze
23
+ @credentials = credentials
24
+ @request_headers = (BASE_REQUEST + names(request_headers)).uniq.freeze
25
+ @expose_headers = (BASE_EXPOSE + names(expose_headers)).uniq.freeze
26
+ freeze
27
+ end
28
+
29
+ private
30
+
31
+ def valid_origin?(origin) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
32
+ return false unless origin.is_a?(String) && origin.ascii_only? && origin.bytesize <= 2048
33
+ return false unless %r{\Ahttps?://[^/?#\s]+\z}.match?(origin)
34
+
35
+ uri = URI.parse(origin)
36
+ host = uri.host
37
+ port = ":#{uri.port}" unless uri.port == uri.default_port
38
+ !!(uri.is_a?(URI::HTTP) && host && !uri.userinfo && uri.to_s == origin &&
39
+ host == host.downcase && origin == "#{uri.scheme}://#{host}#{port}")
40
+ rescue URI::InvalidURIError
41
+ false
42
+ end
43
+
44
+ def names(values) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
45
+ unless values.is_a?(Array) && values.size <= 32 && values.all? do |value|
46
+ value.is_a?(String) && value.bytesize <= 128 && TOKEN.match?(value)
47
+ end
48
+ raise ArgumentError, "CORS header lists require at most 32 HTTP tokens of at most 128 bytes"
49
+ end
50
+
51
+ normalized = values.map { |value| value.downcase.freeze }
52
+ raise ArgumentError, "CORS header names must be distinct" unless normalized.uniq.size == normalized.size
53
+
54
+ normalized
55
+ end
56
+ end
57
+
58
+ class Server
59
+ def cors_response(path:, http_method:, headers:, response: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
60
+ policy = @cors
61
+ return response unless policy && @protocol.include?(:grpc_web) && handles?(path)
62
+
63
+ return response if http_method != "OPTIONS" && request_protocol(headers, http_method) != :grpc_web
64
+
65
+ values = Protocol.headers(headers)
66
+ preflight = http_method == "OPTIONS" &&
67
+ (values.key?("origin") || values.key?("access-control-request-method"))
68
+ return response unless preflight || GRPCWeb.intent?(values)
69
+
70
+ origin = Protocol.single(values, "origin")
71
+ allowed = origin && policy.origins.include?(origin)
72
+ if preflight
73
+ evaluated = cors_preflight(values, policy, origin, allowed)
74
+ return evaluated unless response && evaluated.status == 204
75
+
76
+ decorated = merge_cors_fields(response.headers, evaluated.headers.except("vary"))
77
+ GRPCWeb.vary(decorated, *evaluated.headers.fetch("vary"))
78
+ return TransportResponse.new(status: response.status, headers: decorated, body: response.body)
79
+ end
80
+
81
+ if values.key?("origin") && !allowed
82
+ return TransportResponse.new(status: 403, headers: { "vary" => %w[Origin Accept] }, body: "")
83
+ end
84
+ return unless response
85
+
86
+ # @type var fields: headers
87
+ fields = allowed && origin ? cors_fields(policy, origin) : {}
88
+ merged = merge_cors_fields(response.headers, fields)
89
+ GRPCWeb.vary(merged, "Origin", "Accept")
90
+ TransportResponse.new(status: response.status, headers: merged, body: response.body)
91
+ end
92
+
93
+ private
94
+
95
+ def cors_fields(policy, origin)
96
+ fields = { "access-control-allow-origin" => [origin],
97
+ "access-control-expose-headers" => [policy.expose_headers.join(", ")] }
98
+ fields["access-control-allow-credentials"] = ["true"] if policy.credentials
99
+ fields
100
+ end
101
+
102
+ def merge_cors_fields(headers, fields)
103
+ normalized = Protocol.headers(headers)
104
+ if normalized.any? { |key, values| key.start_with?("access-control-") && fields[key] != values }
105
+ raise Error.new(:internal, "Conflicting application CORS policy"), cause: nil
106
+ end
107
+
108
+ normalized.merge(fields)
109
+ end
110
+
111
+ def cors_preflight(headers, policy, origin, allowed) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
112
+ controls = headers.select { |key, _| key == "origin" || key.start_with?("access-control-request-") }
113
+ if GRPCProtocol.field_size(controls) > 8192
114
+ return TransportResponse.new(status: 403, body: "", headers: {
115
+ "vary" => %w[Origin Access-Control-Request-Method Access-Control-Request-Headers]
116
+ })
117
+ end
118
+ requested = headers.fetch("access-control-request-headers", []).flat_map do |value|
119
+ value.split(",", -1).map(&:strip)
120
+ end
121
+ valid = GRPCProtocol.field_size(controls) <= 8192 && requested.size <= 32 &&
122
+ requested.all? { |value| /\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/.match?(value) }
123
+ permitted = valid && allowed && Protocol.single(headers, "access-control-request-method") == "POST" &&
124
+ requested.all? { |value| policy.request_headers.include?(value.downcase) }
125
+ fields = { "vary" => %w[Origin Access-Control-Request-Method Access-Control-Request-Headers] }
126
+ if permitted && origin
127
+ fields.merge!(cors_fields(policy, origin))
128
+ fields["access-control-allow-methods"] = ["POST"]
129
+ fields["access-control-allow-headers"] = [policy.request_headers.join(", ")]
130
+ fields["access-control-max-age"] = ["0"]
131
+ end
132
+ TransportResponse.new(status: permitted ? 204 : 403, headers: fields, body: "")
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module GRPCWeb
5
+ # Decode each complete quartet independently: padding is permitted at every
6
+ # flush boundary, including inside a frame prefix or protobuf message.
7
+ class Input
8
+ def initialize(input, representation:, wire_limit:, headers:) # rubocop:disable Metrics/MethodLength
9
+ @input = input
10
+ @text = representation == :text
11
+ @encoded_limit = (@text ? 4 : 1) * (5 + wire_limit)
12
+ @decoded_limit = 5 + wire_limit
13
+ length = @length = Protocol.content_length!(headers["content-length"], invalid_code: :invalid_argument)
14
+ if length && length > @encoded_limit
15
+ raise Error.new(:resource_exhausted,
16
+ "Encoded request exceeds byte limit")
17
+ end
18
+
19
+ @encoded = @decoded = 0
20
+ @carry = +"".b
21
+ @remainder = +"".b
22
+ @eof = false
23
+ end
24
+
25
+ def read(length, timeout: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
26
+ while @remainder.empty? && !@eof
27
+ amount = @text ? [((length + 2) / 3) * 4, 16_384].min : [length, 16_384].min
28
+ bytes = @input&.read([amount, @encoded_limit - @encoded + 1].min, timeout: timeout)
29
+ if bytes.nil? || bytes.empty?
30
+ finish_input
31
+ else
32
+ @encoded += bytes.bytesize
33
+ Protocol.check_size!(bytes, amount)
34
+ if @encoded > @encoded_limit
35
+ raise Error.new(:resource_exhausted, "Encoded request exceeds byte limit"), cause: nil
36
+ end
37
+
38
+ @remainder = @text ? decode(bytes) : bytes
39
+ @decoded += @remainder.bytesize
40
+ if @decoded > @decoded_limit
41
+ raise Error.new(:resource_exhausted, "Framed request exceeds byte limit"), cause: nil
42
+ end
43
+ end
44
+ end
45
+ return if @remainder.empty?
46
+
47
+ result = @remainder.byteslice(0, length) #: String
48
+ @remainder = @remainder.byteslice(result.bytesize..) #: String
49
+ result
50
+ end
51
+
52
+ private
53
+
54
+ def decode(bytes) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
55
+ bytes = @carry + bytes
56
+ count = bytes.bytesize - (bytes.bytesize % 4)
57
+ @carry = bytes.byteslice(count..) #: String
58
+ decoded = +"".b
59
+ offset = 0
60
+ while offset < count
61
+ group = bytes.byteslice(offset, 4) #: String
62
+ unless %r{\A(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)\z}.match?(group)
63
+ raise Error.new(:invalid_argument, "Malformed Web base64"), cause: nil
64
+ end
65
+
66
+ value = group.unpack1("m0") #: String
67
+ raise Error.new(:invalid_argument, "Noncanonical Web base64"), cause: nil unless [value].pack("m0") == group
68
+
69
+ decoded << value
70
+ offset += 4
71
+ end
72
+ decoded
73
+ rescue ArgumentError
74
+ raise Error.new(:invalid_argument, "Malformed Web base64"), cause: nil
75
+ end
76
+
77
+ def finish_input
78
+ unless @carry.empty? && (@length.nil? || @length == @encoded)
79
+ raise Error.new(:invalid_argument, "Truncated Web request"), cause: nil
80
+ end
81
+
82
+ @eof = true
83
+ end
84
+ end
85
+
86
+ class Output
87
+ def initialize(output, representation)
88
+ @output = output
89
+ @text = representation == :text
90
+ end
91
+
92
+ def write(bytes, timeout: nil)
93
+ size = @text ? 12_288 : 16_384
94
+ offset = 0
95
+ while offset < bytes.bytesize
96
+ fragment = bytes.byteslice(offset, size) #: String
97
+ @output.write(@text ? [fragment].pack("m0") : fragment, timeout: timeout)
98
+ offset += fragment.bytesize
99
+ end
100
+ nil
101
+ end
102
+
103
+ def finish(terminal:)
104
+ @output.finish(terminal: @text ? [terminal].pack("m0") : terminal)
105
+ end
106
+
107
+ def abort = @output.abort
108
+ end
109
+
110
+ # Portable unary materializes one bounded response, never a native stream.
111
+ class Aggregate
112
+ attr_reader :body
113
+
114
+ def initialize(limit)
115
+ @limit = limit
116
+ @body = +"".b
117
+ end
118
+
119
+ def write(bytes, timeout: nil) # rubocop:disable Lint/UnusedMethodArgument -- synchronous bounded append
120
+ if @body.bytesize + bytes.bytesize > @limit
121
+ raise Error.new(:resource_exhausted,
122
+ "Aggregate Web response exceeds byte limit")
123
+ end
124
+
125
+ @body << bytes
126
+ nil
127
+ end
128
+
129
+ def finish(terminal:)
130
+ @terminal = terminal
131
+ nil
132
+ end
133
+
134
+ def complete
135
+ terminal = @terminal
136
+ write(terminal) if terminal
137
+ end
138
+
139
+ def abort
140
+ @body.clear
141
+ @terminal = nil
142
+ nil
143
+ end
144
+ end
145
+
146
+ class PortableInput
147
+ def initialize(input)
148
+ @input = input
149
+ end
150
+
151
+ def read(length, timeout: nil) # rubocop:disable Lint/UnusedMethodArgument -- portable cooperative I/O
152
+ @input&.read(length)
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module GRPCWeb
5
+ class ServerState
6
+ attr_reader :headers, :publish, :lifecycle, :cancellation, :representation, :path
7
+ attr_accessor :context, :session, :error, :published
8
+
9
+ def initialize(path:, headers:, representation:, publish:, lifecycle:, cancellation:)
10
+ @path = path
11
+ @headers = headers
12
+ @representation = representation
13
+ @publish = publish
14
+ @lifecycle = lifecycle
15
+ @cancellation = cancellation
16
+ @published = false
17
+ end
18
+ end
19
+ end
20
+
21
+ class Server # rubocop:disable Metrics/ClassLength
22
+ # HTTP-version-neutral publication. Hosts stage finish's terminal until the
23
+ # original application task has completed all outer cleanup successfully.
24
+ def call_grpc_web(path:, http_method:, headers:, input:, output:, publish:, capabilities: [:unary], # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
25
+ lifecycle: nil, cancellation: nil)
26
+ # @type var owned: bool?
27
+ owned = nil
28
+ # @type var error: Exception?
29
+ error = nil
30
+ GRPCWeb.capabilities!(capabilities)
31
+ values = Protocol.headers(headers)
32
+ early = cors_response(path: path, http_method: http_method, headers: values)
33
+ return early if early
34
+
35
+ lifecycle, owned = begin_native_processing(path, values, lifecycle, http_method)
36
+ work = lambda do
37
+ rejection = web_preflight(http_method, values, cancellation)
38
+ if rejection
39
+ lifecycle&.processing_finished(code: nil, http_status: rejection.status)
40
+ next cors_response(path: path, http_method: http_method, headers: values, response: rejection) || rejection
41
+ end
42
+
43
+ request = GRPCWeb.media(Protocol.single(values, "content-type")) #: GRPCWeb::representation
44
+ representation = GRPCWeb.representation(values, request) #: GRPCWeb::representation
45
+ state = GRPCWeb::ServerState.new(path: path, headers: values, representation: representation,
46
+ publish: publish, lifecycle: lifecycle, cancellation: cancellation)
47
+ process_grpc_web(state, input, output, request, capabilities)
48
+ nil
49
+ end
50
+ lifecycle ? lifecycle.activate(&work) : work.call
51
+ rescue Exception => e # rubocop:disable Lint/RescueException -- discard staged status and preserve the original failure
52
+ error = e
53
+ lifecycle&.record_failure(e) unless owned.nil?
54
+ begin
55
+ output.abort unless owned.nil?
56
+ rescue StandardError => cleanup_error
57
+ lifecycle&.record_failure(cleanup_error)
58
+ end
59
+ raise
60
+ ensure
61
+ finish_native_processing(lifecycle, owned, error) unless owned.nil?
62
+ end
63
+
64
+ private
65
+
66
+ def web_preflight(http_method, headers, cancellation)
67
+ fields = { "vary" => ["Accept"] }
68
+ return bare(415, fields, cancellation: cancellation) unless @protocol.include?(:grpc_web)
69
+ return bare(405, fields.merge("allow" => ["POST"]), cancellation: cancellation) unless http_method == "POST"
70
+
71
+ representation = GRPCWeb.media(Protocol.single(headers, "content-type"))
72
+ unless representation
73
+ return bare(415, fields.merge("accept-post" => [GRPCWeb::CONTENT_TYPES.values.join(", ")]),
74
+ cancellation: cancellation)
75
+ end
76
+ return if GRPCWeb.representation(headers, representation)
77
+
78
+ bare(406, fields, cancellation: cancellation)
79
+ end
80
+
81
+ def call_grpc_web_unary(path, http_method, headers, input, lifecycle, cancellation) # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
82
+ # Separately padded writes can expand every raw byte by four. The bounded
83
+ # aggregate includes one data prefix and the separately staged terminal.
84
+ output = GRPCWeb::Aggregate.new(4 * (5 + @response_wire_limit + 5 + @grpc_status_wire_limit))
85
+ head = nil #: StreamHead?
86
+ response = call_grpc_web(path: path, http_method: http_method, headers: headers,
87
+ input: GRPCWeb::PortableInput.new(input), output: output,
88
+ lifecycle: lifecycle, cancellation: cancellation,
89
+ publish: ->(published) { head = published })
90
+ return response if response
91
+
92
+ output.complete
93
+ published = head #: StreamHead
94
+ TransportResponse.new(status: published.status, headers: published.headers, body: output.body)
95
+ rescue Exception # rubocop:disable Lint/RescueException -- discard staged status, preserve arbitrary cleanup failure
96
+ output&.abort
97
+ raise
98
+ end
99
+
100
+ def process_grpc_web(state, input, output, request, capabilities)
101
+ prepared = GRPCWeb::Output.new(output, state.representation)
102
+ capture_stream_error(state) { execute_grpc_web(state, input, prepared, request, capabilities) }
103
+ render_grpc_web_terminal(state, prepared)
104
+ ensure
105
+ state.session&.close
106
+ state.context&.close
107
+ end
108
+
109
+ def execute_grpc_web(state, input, output, representation, capabilities) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
110
+ GRPCProtocol.check_fields!(state.headers)
111
+ declaration = @methods[state.path]
112
+ allowed = if declaration.is_a?(Method)
113
+ capabilities.include?(:unary)
114
+ else
115
+ declaration.is_a?(ServerStreamMethod) && capabilities.include?(:server_stream) &&
116
+ @handlers[state.path]
117
+ end
118
+ raise Error.new(:unimplemented, "Unsupported Web method"), cause: nil unless allowed
119
+
120
+ GRPCProtocol.encoding(state.headers, request: true, enabled: :identity)
121
+ unless Protocol.identity_encoding?(state.headers)
122
+ raise Error.new(:unimplemented, "Web requires identity HTTP encoding"), cause: nil
123
+ end
124
+
125
+ context = state.context = web_context(state, declaration)
126
+ incoming = GRPCWeb::Input.new(input, representation: representation, wire_limit: @request_wire_limit,
127
+ headers: state.headers)
128
+ session = state.session = Streaming::Session.new(
129
+ binding: @binding, method: declaration, codec: :proto, state: context, input: incoming, output: output,
130
+ request_limit: @request_limit, response_limit: @response_limit,
131
+ request_wire_limit: @request_wire_limit, response_wire_limit: @response_wire_limit,
132
+ request_encoding: :identity, response_encoding: :identity,
133
+ terminal_wire_limit: @grpc_status_wire_limit, terminal_limit: @grpc_status_limit,
134
+ server: true, context: context, lifecycle: state.lifecycle,
135
+ publish: -> { publish_web_head(state) }, strategy: GRPCWeb::Strategy.new
136
+ )
137
+ message = session.receive
138
+ if message.nil? || incoming.read(1, timeout: context.deadline&.remaining)
139
+ raise Error.new(:unimplemented, "Expected exactly one request"), cause: nil
140
+ end
141
+
142
+ context.check!
143
+ if declaration.is_a?(Method)
144
+ session.send_message(@invoker.call(declaration, message, context))
145
+ else
146
+ handler = @handlers.fetch(state.path) #: _ServerStreamHandler[untyped, untyped]
147
+ streaming = context #: StreamingContext
148
+ handler.call(message, ResponseWriter.new(session, streaming), streaming)
149
+ end
150
+ context.check!
151
+ end
152
+
153
+ def web_context(state, declaration = nil, headers: state.headers)
154
+ metadata = Protocol.metadata(headers)
155
+ state.lifecycle.request_metadata = Instrumentation.carrier(metadata) if state.lifecycle
156
+ options = { request_metadata: metadata, deadline: GRPCProtocol.request_deadline(headers),
157
+ cancellation: state.cancellation, lifecycle: state.lifecycle }
158
+ return Context.new(**options) unless declaration.is_a?(ServerStreamMethod)
159
+
160
+ StreamingContext.new(publish: ->(snapshot) { publish_web_snapshot(state, snapshot) }, **options)
161
+ end
162
+
163
+ def publish_web_head(state)
164
+ return if state.published
165
+
166
+ context = state.context #: Context
167
+ if context.is_a?(StreamingContext)
168
+ context.send(:publish_headers, check: false)
169
+ else
170
+ context.response_metadata.freeze
171
+ publish_web_snapshot(state, context.response_metadata)
172
+ end
173
+ end
174
+
175
+ def publish_web_snapshot(state, metadata) # rubocop:disable Metrics/AbcSize
176
+ fields = metadata.to_h.merge("content-type" => [GRPCWeb::CONTENT_TYPES.fetch(state.representation)],
177
+ "grpc-accept-encoding" => ["identity"])
178
+ GRPCWeb.vary(fields, "Accept")
179
+ response = TransportResponse.new(status: 200, headers: fields, body: "")
180
+ response = cors_response(path: state.path, http_method: "POST", headers: state.headers,
181
+ response: response) || response
182
+ GRPCProtocol.check_fields!(response.headers.merge(":status" => [response.status.to_s]))
183
+ state.publish.call(StreamHead.new(status: response.status, headers: response.headers))
184
+ state.published = true
185
+ nil
186
+ end
187
+
188
+ def render_grpc_web_terminal(state, output) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
189
+ context = state.context ||= web_context(state, headers: {})
190
+ merge_stream_error_metadata(state.error, context)
191
+ codec = @status_codec #: _GRPCStatusCodec
192
+ bytes, effective = GRPCWeb.terminal(
193
+ state.error, stream_trailers(state.error, context),
194
+ codec: codec,
195
+ wire_limit: @grpc_status_wire_limit, limit: @grpc_status_limit,
196
+ on_error: lambda { |error|
197
+ state.lifecycle&.record_failure(error)
198
+ }
199
+ )
200
+ observe_stream_result(state, effective) if state.error
201
+ publish_web_head(state)
202
+ output.finish(terminal: bytes)
203
+ context.commit! unless state.error
204
+ observe_stream_result(state, effective) unless state.error
205
+ end
206
+ end
207
+ end