grpc-dc 0.37.1

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,262 @@
1
+ # @generated by grpc-dc codegen. DO NOT EDIT.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Codec + status types for gRPC over WebRTC DataChannels.
4
+ #
5
+ # Wire protocol in ../SPEC.md. Frame, status, channel and server
6
+ # semantics mirror the Rust, JavaScript, Go and Python runtimes;
7
+ # the tables in wire_gen.rb are generated by grpc-dc codegen.
8
+
9
+ require "json"
10
+ require_relative "wire_gen"
11
+
12
+ module Grpcdc
13
+ HEADER_LEN = 9
14
+ MAX_DATA_PAYLOAD = 16 * 1024 * 1024
15
+
16
+ # Canonical status name ("CODE_N" for unknown codes).
17
+ def self.status_name(code)
18
+ STATUS_NAMES.fetch(code, "CODE_#{code}")
19
+ end
20
+
21
+ # A gRPC status (code + message) carried as an exception.
22
+ # (message is an attribute, like the Python runtime; note
23
+ # Exception#message normally delegates to to_s, so to_s must
24
+ # build from the attribute, never from itself.)
25
+ class StatusError < StandardError
26
+ attr_reader :code, :message
27
+
28
+ # Trailing metadata carried by a status frame (never nil).
29
+ attr_accessor :trailers
30
+
31
+ def initialize(code, message = "")
32
+ super(message)
33
+ @code = code
34
+ @message = message
35
+ @trailers = {}
36
+ end
37
+
38
+ def to_s
39
+ "#{Grpcdc.status_name(@code)}: #{@message}"
40
+ end
41
+ end
42
+
43
+ # The call completed OK (client-side stream end).
44
+ class CallClosed < EOFError; end
45
+
46
+ # --- Homegrown tracing (W3C traceparent propagation) ----------
47
+ # Dormant until Grpcdc.set_tracer registers an observer with
48
+ # on_start(kind, method, trace_id, span_id, parent_span_id) and
49
+ # on_end(kind, method, trace_id, span_id, status_name).
50
+ # Generated code stays dependency-free: sampling and export are
51
+ # the application's problem.
52
+ @tracer = nil
53
+
54
+ def self.set_tracer(tracer)
55
+ @tracer = tracer
56
+ end
57
+
58
+ def self.tracer
59
+ @tracer
60
+ end
61
+
62
+ @trace_filter = []
63
+
64
+ # Restrict traced methods: ';'-separated paths, a trailing
65
+ # '*' means prefix match ('/echo.Echo/*'). Empty = trace all.
66
+ def self.set_trace_filter(patterns)
67
+ @trace_filter = patterns.split(";").reject(&:empty?)
68
+ end
69
+
70
+ def self.trace_allowed?(method)
71
+ return true if @trace_filter.empty?
72
+
73
+ @trace_filter.any? do |p|
74
+ p.end_with?("*") ? method.start_with?(p[0...-1]) : method == p
75
+ end
76
+ end
77
+
78
+ def self.format_traceparent(trace_id, span_id)
79
+ "00-#{trace_id}-#{span_id}-01"
80
+ end
81
+
82
+ # Parse a traceparent header -> [trace_id, span_id] or nil.
83
+ def self.parse_traceparent(value)
84
+ return nil unless value.is_a?(String)
85
+
86
+ parts = value.split("-")
87
+ return nil unless parts.length == 4
88
+
89
+ trace_id, span_id = parts[1], parts[2]
90
+ return nil unless trace_id.length == 32 && span_id.length == 16
91
+ return nil unless trace_id.match?(/\A[0-9a-f]+\z/) && span_id.match?(/\A[0-9a-f]+\z/)
92
+
93
+ [trace_id, span_id]
94
+ end
95
+
96
+ def self.trace_start(kind, method, parent)
97
+ return nil if @tracer.nil? || !trace_allowed?(method)
98
+
99
+ require "securerandom"
100
+ trace_id, parent_span_id = parent || [SecureRandom.hex(16), ""]
101
+ ctx = {
102
+ "trace_id" => trace_id,
103
+ "span_id" => SecureRandom.hex(8),
104
+ "parent_span_id" => parent_span_id
105
+ }
106
+ begin
107
+ @tracer.on_start(kind, method, ctx["trace_id"], ctx["span_id"], parent_span_id)
108
+ rescue StandardError
109
+ # observers must never break the call
110
+ end
111
+ ctx
112
+ end
113
+
114
+ def self.trace_end(kind, method, ctx, status)
115
+ return if ctx.nil? || @tracer.nil?
116
+
117
+ begin
118
+ @tracer.on_end(kind, method, ctx["trace_id"], ctx["span_id"], status)
119
+ rescue StandardError
120
+ # observers must never break the call
121
+ end
122
+ end
123
+
124
+ # Convert an exception to StatusError (OK for nil).
125
+ def self.to_status(err)
126
+ return StatusError.new(CODE_OK, "") if err.nil?
127
+ return err if err.is_a?(StatusError)
128
+
129
+ StatusError.new(CODE_INTERNAL, err.message)
130
+ end
131
+
132
+ # A parsed HEADERS payload: path=nil marks response headers.
133
+ class Headers
134
+ attr_reader :path, :metadata
135
+
136
+ def initialize(path = nil, metadata = nil, timeout: nil)
137
+ @path = path
138
+ @metadata = metadata || {}
139
+ end
140
+
141
+ def inspect
142
+ "Headers(path=#{@path.inspect}, metadata=#{@metadata.inspect})"
143
+ end
144
+ end
145
+
146
+ module Codec
147
+ module_function
148
+
149
+ # Build one DataChannel message: header + payload.
150
+ def encode_frame(frame_type, stream_id, payload)
151
+ unless FRAME_NAMES.value?(frame_type)
152
+ raise StatusError.new(CODE_INVALID_ARGUMENT, "unknown frame type #{frame_type}")
153
+ end
154
+ payload = payload.b
155
+ if frame_type == FRAME_DATA && payload.bytesize > MAX_DATA_PAYLOAD
156
+ raise StatusError.new(CODE_RESOURCE_EXHAUSTED, "DATA payload exceeds 16 MiB")
157
+ end
158
+
159
+ [frame_type, stream_id, payload.bytesize].pack("CNN") + payload
160
+ end
161
+
162
+ # Parse one DataChannel message -> [frame_type, stream_id, payload].
163
+ def decode_frame(data)
164
+ if data.bytesize < HEADER_LEN
165
+ raise StatusError.new(
166
+ CODE_INVALID_ARGUMENT,
167
+ "frame shorter than 9-byte header: #{data.bytesize} bytes"
168
+ )
169
+ end
170
+ frame_type, stream_id, length = data.byteslice(0, HEADER_LEN).unpack("CNN")
171
+ if length != data.bytesize - HEADER_LEN
172
+ raise StatusError.new(
173
+ CODE_INVALID_ARGUMENT,
174
+ "payload length #{length} does not match message size #{data.bytesize}"
175
+ )
176
+ end
177
+
178
+ [frame_type, stream_id, data.byteslice(HEADER_LEN..-1) || "".b]
179
+ end
180
+
181
+ def headers_frame(stream_id, path, metadata)
182
+ body = {}
183
+ body["path"] = path unless path.nil?
184
+ body["metadata"] = metadata if metadata && !metadata.empty?
185
+ encode_frame(FRAME_HEADERS, stream_id, JSON.generate(body).b)
186
+ end
187
+
188
+ def parse_headers(payload)
189
+ begin
190
+ body = JSON.parse(payload.dup.force_encoding(Encoding::UTF_8))
191
+ rescue JSON::ParserError, EncodingError => e
192
+ raise StatusError.new(CODE_INVALID_ARGUMENT, "bad HEADERS payload: #{e.message}")
193
+ end
194
+ unless body.is_a?(Hash)
195
+ raise StatusError.new(CODE_INVALID_ARGUMENT, "HEADERS payload is not an object")
196
+ end
197
+ metadata = body["metadata"] || {}
198
+ unless metadata.is_a?(Hash)
199
+ raise StatusError.new(CODE_INVALID_ARGUMENT, "HEADERS metadata is not an object")
200
+ end
201
+
202
+ Headers.new(body["path"], metadata)
203
+ end
204
+
205
+ # gRPC timeout grammar (grpc-timeout metadata): <digits><unit>,
206
+ # units H/M/S/m/u/n (at most 8 digits). Encoding uses
207
+ # milliseconds (seconds past the digit limit), rounded up.
208
+ def encode_timeout(seconds)
209
+ ms = (seconds * 1000.0).ceil
210
+ return "#{ms}m" if ms <= 99999999
211
+
212
+ "#{seconds.ceil}S"
213
+ end
214
+
215
+ # Decoded grpc-timeout in seconds, or nil when malformed.
216
+ def parse_timeout(text)
217
+ return nil unless text.is_a?(String) && text.length >= 2 && text.length <= 9
218
+
219
+ digits = text[0...-1]
220
+ return nil unless digits.match?(/\A\d+\z/)
221
+
222
+ n = digits.to_i.to_f
223
+ case text[-1]
224
+ when "H" then n * 3600.0
225
+ when "M" then n * 60.0
226
+ when "S" then n
227
+ when "m" then n / 1000.0
228
+ when "u" then n / 1000000.0
229
+ when "n" then n / 1000000000.0
230
+ end
231
+ end
232
+
233
+ def status_frame(frame_type, stream_id, status)
234
+ body = { "code" => status.code, "message" => status.message }
235
+ body["metadata"] = status.trailers if status.trailers && !status.trailers.empty?
236
+ encode_frame(frame_type, stream_id, JSON.generate(body).b)
237
+ end
238
+
239
+ def parse_status(payload)
240
+ body = JSON.parse(payload.dup.force_encoding(Encoding::UTF_8))
241
+ status = StatusError.new(Integer(body.fetch("code")), String(body["message"] || ""))
242
+ md = body["metadata"]
243
+ if md.is_a?(Hash)
244
+ status.trailers = md.each_with_object({}) do |(k, v), out|
245
+ out[k.to_s] = v.to_s
246
+ end
247
+ end
248
+ status
249
+ rescue JSON::ParserError, EncodingError, KeyError, TypeError, ArgumentError
250
+ StatusError.new(CODE_UNKNOWN, "malformed trailers")
251
+ end
252
+ end
253
+
254
+ # Convenience delegates (the Python runtime's flat surface).
255
+ def self.encode_frame(frame_type, stream_id, payload)
256
+ Codec.encode_frame(frame_type, stream_id, payload)
257
+ end
258
+
259
+ def self.decode_frame(data)
260
+ Codec.decode_frame(data)
261
+ end
262
+ end