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.
- checksums.yaml +7 -0
- data/LICENSE +201 -0
- data/README.md +10 -0
- data/lib/grpc_dc.rb +1 -0
- data/lib/grpcdc/channel.rb +327 -0
- data/lib/grpcdc/codec.rb +262 -0
- data/lib/grpcdc/dynamic.rb +677 -0
- data/lib/grpcdc/endpoint.rb +397 -0
- data/lib/grpcdc/eventbus.rb +250 -0
- data/lib/grpcdc/eventbus_git.rb +132 -0
- data/lib/grpcdc/events_grpc_dc.rb +232 -0
- data/lib/grpcdc/events_pb.rb +432 -0
- data/lib/grpcdc/loopback.rb +43 -0
- data/lib/grpcdc/server.rb +106 -0
- data/lib/grpcdc/sidecar.rb +63 -0
- data/lib/grpcdc/wire_gen.rb +58 -0
- data/lib/grpcdc.rb +12 -0
- metadata +61 -0
data/lib/grpcdc/codec.rb
ADDED
|
@@ -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
|