ultimate_json_rpc 1.0.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/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +478 -0
- data/Rakefile +12 -0
- data/examples/error_handling.rb +51 -0
- data/examples/mcp_server.rb +42 -0
- data/examples/multi_namespace.rb +49 -0
- data/examples/rack_server.ru +24 -0
- data/examples/testing_patterns.rb +49 -0
- data/examples/websocket_server.ru +42 -0
- data/lib/ultimate_json_rpc/core/errors.rb +85 -0
- data/lib/ultimate_json_rpc/core/handler.rb +308 -0
- data/lib/ultimate_json_rpc/core/request.rb +74 -0
- data/lib/ultimate_json_rpc/core/response.rb +28 -0
- data/lib/ultimate_json_rpc/extras/docs.rb +107 -0
- data/lib/ultimate_json_rpc/extras/logging.rb +21 -0
- data/lib/ultimate_json_rpc/extras/mcp.rb +144 -0
- data/lib/ultimate_json_rpc/extras/profiler.rb +87 -0
- data/lib/ultimate_json_rpc/extras/rate_limit.rb +57 -0
- data/lib/ultimate_json_rpc/extras/recorder.rb +67 -0
- data/lib/ultimate_json_rpc/extras/test_helpers.rb +53 -0
- data/lib/ultimate_json_rpc/server.rb +359 -0
- data/lib/ultimate_json_rpc/transport/rack.rb +51 -0
- data/lib/ultimate_json_rpc/transport/stdio.rb +63 -0
- data/lib/ultimate_json_rpc/transport/tcp.rb +153 -0
- data/lib/ultimate_json_rpc/transport/websocket.rb +29 -0
- data/lib/ultimate_json_rpc/version.rb +5 -0
- data/lib/ultimate_json_rpc.rb +16 -0
- data/sig/ultimate_json_rpc.rbs +208 -0
- metadata +75 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module UltimateJsonRpc
|
|
6
|
+
GENERIC_ERROR_DATA = "Internal server error"
|
|
7
|
+
GENERIC_PARAMS_DATA = "Invalid method parameters"
|
|
8
|
+
private_constant :GENERIC_ERROR_DATA, :GENERIC_PARAMS_DATA
|
|
9
|
+
|
|
10
|
+
module BatchProcessor
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def handle_batch(requests)
|
|
14
|
+
return @json.generate(Core::Response.error(Core::INVALID_REQUEST, nil)) if requests.empty?
|
|
15
|
+
if batch_too_large?(requests)
|
|
16
|
+
return @json.generate(Core::Response.error(Core::INVALID_REQUEST, nil, data: "Batch too large"))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
json_parts = process_batch_items(requests)
|
|
20
|
+
json_parts.empty? ? nil : "[#{json_parts.join(",")}]"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def process_batch_items(requests)
|
|
24
|
+
if @concurrent_batches
|
|
25
|
+
pool_dispatch(requests).compact
|
|
26
|
+
else
|
|
27
|
+
requests.filter_map { |req| serialize_single(req) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def pool_dispatch(requests)
|
|
32
|
+
queue = Queue.new
|
|
33
|
+
requests.each_with_index { |req, i| queue << [req, i] }
|
|
34
|
+
results = Array.new(requests.size)
|
|
35
|
+
spawn_workers(queue:, results:, pool_size: [requests.size, @max_concurrency].min)
|
|
36
|
+
results
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def spawn_workers(queue:, results:, pool_size:)
|
|
40
|
+
mutex = Mutex.new
|
|
41
|
+
pool_size.times.map do
|
|
42
|
+
Thread.new { drain_queue(queue, results, mutex) }
|
|
43
|
+
end.each(&:join)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def drain_queue(queue, results, mutex)
|
|
47
|
+
loop do
|
|
48
|
+
req, i = begin
|
|
49
|
+
queue.pop(true)
|
|
50
|
+
rescue ThreadError
|
|
51
|
+
break
|
|
52
|
+
end
|
|
53
|
+
result = safe_serialize(req)
|
|
54
|
+
mutex.synchronize { results[i] = result }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def safe_serialize(data)
|
|
59
|
+
serialize_single(data)
|
|
60
|
+
rescue Exception # rubocop:disable Lint/RescueException -- worker threads must never die silently
|
|
61
|
+
id = data.is_a?(Hash) ? extract_id(data) : nil
|
|
62
|
+
begin
|
|
63
|
+
@json.generate(Core::Response.error(Core::INTERNAL_ERROR, id))
|
|
64
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
65
|
+
id_json = format_fallback_id(id)
|
|
66
|
+
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"},\"id\":#{id_json}}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def format_fallback_id(id)
|
|
71
|
+
case id
|
|
72
|
+
when Numeric then id.to_s
|
|
73
|
+
when String then JSON.generate(id) rescue "null" # rubocop:disable Style/RescueModifier
|
|
74
|
+
else "null"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def batch_too_large?(requests) = @max_batch_size && requests.size > @max_batch_size
|
|
79
|
+
end
|
|
80
|
+
private_constant :BatchProcessor
|
|
81
|
+
|
|
82
|
+
module OpenRPCBuilder
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def build_openrpc_document
|
|
86
|
+
doc = { "openrpc" => "1.3.2", "info" => build_info, "methods" => @handler.methods_info }
|
|
87
|
+
doc.delete("info") if doc["info"].empty?
|
|
88
|
+
doc["components"] = { "errors" => @error_catalog } if @error_catalog&.any?
|
|
89
|
+
doc
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_info
|
|
93
|
+
{ "title" => @name, "version" => @version, "description" => @description }.compact
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
private_constant :OpenRPCBuilder
|
|
97
|
+
|
|
98
|
+
module ServerExtensions
|
|
99
|
+
def register_error(code:, message:, description: nil)
|
|
100
|
+
raise ArgumentError, "error code must be an Integer" unless code.is_a?(Integer)
|
|
101
|
+
|
|
102
|
+
if code.between?(Core::RESERVED_ERROR_MIN, Core::RESERVED_ERROR_MAX)
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"error code #{code} is in the reserved JSON-RPC range " \
|
|
105
|
+
"(#{Core::RESERVED_ERROR_MIN}..#{Core::RESERVED_ERROR_MAX})"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@error_catalog ||= []
|
|
109
|
+
raise ArgumentError, "error code #{code} is already registered" if @error_catalog.any? { |e| e["code"] == code }
|
|
110
|
+
|
|
111
|
+
entry = { "code" => code, "message" => message.to_s }
|
|
112
|
+
entry["data"] = description.to_s if description
|
|
113
|
+
@error_catalog << entry
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def authorize(*patterns, code: 403, message: "Forbidden", &block)
|
|
118
|
+
raise ArgumentError, "block required" unless block
|
|
119
|
+
|
|
120
|
+
validate_authorize_code!(code)
|
|
121
|
+
|
|
122
|
+
opts = patterns.empty? ? {} : { only: patterns }
|
|
123
|
+
use(**opts) do |request, next_call|
|
|
124
|
+
raise Core::ApplicationError.new(code:, message:) unless block.call(request)
|
|
125
|
+
|
|
126
|
+
next_call.call
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def validate_authorize_code!(code)
|
|
133
|
+
raise ArgumentError, "authorize code must be an Integer" unless code.is_a?(Integer)
|
|
134
|
+
return unless code.between?(Core::RESERVED_ERROR_MIN, Core::RESERVED_ERROR_MAX)
|
|
135
|
+
|
|
136
|
+
raise ArgumentError,
|
|
137
|
+
"authorize code #{code} is in the reserved JSON-RPC range " \
|
|
138
|
+
"(#{Core::RESERVED_ERROR_MIN}..#{Core::RESERVED_ERROR_MAX})"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
private_constant :ServerExtensions
|
|
142
|
+
|
|
143
|
+
class Server
|
|
144
|
+
include BatchProcessor
|
|
145
|
+
include OpenRPCBuilder
|
|
146
|
+
include ServerExtensions
|
|
147
|
+
|
|
148
|
+
attr_reader :name, :version, :description, :max_batch_size, :timeout, :json_adapter
|
|
149
|
+
|
|
150
|
+
HOOK_EVENTS = %i[request response error].freeze
|
|
151
|
+
private_constant :HOOK_EVENTS
|
|
152
|
+
|
|
153
|
+
def initialize(name: nil, version: nil, description: nil, max_batch_size: 100, expose_errors: false,
|
|
154
|
+
timeout: nil, concurrent_batches: false, max_concurrency: 8, json: JSON)
|
|
155
|
+
@name = name
|
|
156
|
+
@version = version
|
|
157
|
+
@description = description
|
|
158
|
+
@max_batch_size = max_batch_size
|
|
159
|
+
@expose_errors = expose_errors
|
|
160
|
+
@timeout = timeout
|
|
161
|
+
@concurrent_batches = concurrent_batches
|
|
162
|
+
@max_concurrency = max_concurrency
|
|
163
|
+
@json = @json_adapter = json
|
|
164
|
+
@handler = Core::Handler.new
|
|
165
|
+
@middleware = []
|
|
166
|
+
@hooks = HOOK_EVENTS.to_h { |e| [e, []] }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def expose(target, namespace: nil, only: nil, except: nil, descriptions: nil, returns: nil, deprecated: nil,
|
|
170
|
+
params_schema: nil)
|
|
171
|
+
@handler.expose(target, namespace:, only:, except:, descriptions:, returns:, deprecated:, params_schema:)
|
|
172
|
+
self
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def expose_method(name, callable = nil, description: nil, returns: nil, deprecated: nil, params_schema: nil, &)
|
|
176
|
+
@handler.expose_method(name, callable, description:, returns:, deprecated:, params_schema:, &)
|
|
177
|
+
self
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def use(only: nil, except: nil, &block)
|
|
181
|
+
raise ArgumentError, "block required" unless block
|
|
182
|
+
raise ArgumentError, "cannot use both :only and :except" if only && except
|
|
183
|
+
|
|
184
|
+
@middleware << [block, middleware_matcher(only, except)]
|
|
185
|
+
self
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def on(event, &block)
|
|
189
|
+
raise ArgumentError, "block required" unless block
|
|
190
|
+
raise ArgumentError, "unknown event: #{event}" unless HOOK_EVENTS.include?(event)
|
|
191
|
+
|
|
192
|
+
@hooks[event] << block
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def handle(json_string)
|
|
197
|
+
data = @json.parse(json_string)
|
|
198
|
+
rescue StandardError
|
|
199
|
+
@json.generate(Core::Response.error(Core::PARSE_ERROR, nil))
|
|
200
|
+
else
|
|
201
|
+
handle_parsed(data)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
alias call handle
|
|
205
|
+
|
|
206
|
+
def handle_parsed(data)
|
|
207
|
+
case data
|
|
208
|
+
when Array then handle_batch(data)
|
|
209
|
+
when Hash then serialize_single(data)
|
|
210
|
+
else @json.generate(Core::Response.error(Core::INVALID_REQUEST, nil))
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def to_proc = method(:call).to_proc
|
|
215
|
+
def inspect = "#<#{self.class}#{" name=#{@name.inspect}" if @name} methods=#{size} middleware=#{@middleware.size}>"
|
|
216
|
+
def expose_errors? = @expose_errors
|
|
217
|
+
def concurrent_batches? = @concurrent_batches
|
|
218
|
+
|
|
219
|
+
def freeze
|
|
220
|
+
return self if frozen?
|
|
221
|
+
|
|
222
|
+
@hooks.each_value(&:freeze)
|
|
223
|
+
[@handler, @middleware, @hooks].each(&:freeze)
|
|
224
|
+
@error_catalog&.each(&:freeze)
|
|
225
|
+
@error_catalog&.freeze
|
|
226
|
+
precompile_middleware
|
|
227
|
+
super
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def methods_list = @handler.methods_list
|
|
231
|
+
def methods_info = @handler.methods_info
|
|
232
|
+
def method?(method_name) = @handler.method?(method_name)
|
|
233
|
+
def size = @handler.size
|
|
234
|
+
def empty? = @handler.empty?
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
def serialize_single(data)
|
|
239
|
+
request = parse_request(data)
|
|
240
|
+
return request unless request.is_a?(Core::Request)
|
|
241
|
+
|
|
242
|
+
response = execute_request(request)
|
|
243
|
+
return nil unless response
|
|
244
|
+
|
|
245
|
+
@json.generate(response)
|
|
246
|
+
rescue StandardError
|
|
247
|
+
return nil if request.is_a?(Core::Request) && request.notification?
|
|
248
|
+
|
|
249
|
+
id = request.is_a?(Core::Request) ? request.id : extract_id(data)
|
|
250
|
+
begin
|
|
251
|
+
@json.generate(Core::Response.error(Core::INTERNAL_ERROR, id))
|
|
252
|
+
rescue StandardError
|
|
253
|
+
id_json = format_fallback_id(id)
|
|
254
|
+
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"},\"id\":#{id_json}}"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def parse_request(data)
|
|
259
|
+
Core::Request.new(data)
|
|
260
|
+
rescue Core::InvalidRequest
|
|
261
|
+
@json.generate(Core::Response.error(Core::INVALID_REQUEST, extract_id(data)))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def extract_id(data)
|
|
265
|
+
return nil unless data.is_a?(Hash)
|
|
266
|
+
|
|
267
|
+
data["id"].then { |id| id.nil? || id.is_a?(String) || id.is_a?(Numeric) ? id : nil }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def execute_request(request)
|
|
271
|
+
emit(:request, request)
|
|
272
|
+
result, error, _duration = timed_dispatch(request)
|
|
273
|
+
error ? handle_dispatch_error(request:, error:) : handle_dispatch_success(request, result)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def timed_dispatch(request)
|
|
277
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
278
|
+
result = with_timeout { build_chain(request).call }
|
|
279
|
+
duration = elapsed(start)
|
|
280
|
+
emit(:response, request, result, duration)
|
|
281
|
+
[result, nil, duration]
|
|
282
|
+
rescue StandardError => e
|
|
283
|
+
duration = elapsed(start)
|
|
284
|
+
emit(:error, request, e, duration)
|
|
285
|
+
[nil, e, duration]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def handle_dispatch_success(request, result)
|
|
289
|
+
request.notification? ? nil : Core::Response.success(result, request.id)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def handle_dispatch_error(request:, error:)
|
|
293
|
+
return nil if request.notification?
|
|
294
|
+
|
|
295
|
+
code, message, data = error_details(error)
|
|
296
|
+
Core::Response.error(code, request.id, data: data, message: message)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def emit(event, *args)
|
|
300
|
+
@hooks[event].each do |hook|
|
|
301
|
+
hook.call(*args)
|
|
302
|
+
rescue StandardError => e
|
|
303
|
+
Kernel.warn "UltimateJsonRpc: #{event} hook error: #{e.message}"
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def elapsed(start) = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
308
|
+
|
|
309
|
+
def precompile_middleware
|
|
310
|
+
methods = @handler.methods_list + ["rpc.discover"]
|
|
311
|
+
@middleware_index = methods.to_h do |name|
|
|
312
|
+
[name, @middleware.select { |_, matcher| matcher.call(name) }.freeze]
|
|
313
|
+
end.freeze
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def build_chain(request)
|
|
317
|
+
applicable = @middleware_index&.[](request.method_name) ||
|
|
318
|
+
@middleware.select { |_, matcher| matcher.call(request.method_name) }
|
|
319
|
+
applicable.reverse.reduce(-> { invoke_handler(request) }) do |core, (mw, _)|
|
|
320
|
+
-> { mw.call(request, core) }
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def middleware_matcher(only, except)
|
|
325
|
+
return ->(_) { true } unless only || except
|
|
326
|
+
|
|
327
|
+
patterns = Array(only || except).map(&:to_s)
|
|
328
|
+
check = only ? :any? : :none?
|
|
329
|
+
->(name) { patterns.public_send(check) { |p| p.include?("*") ? File.fnmatch(p, name) : p == name } }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def with_timeout(&)
|
|
333
|
+
return yield unless @timeout
|
|
334
|
+
|
|
335
|
+
Timeout.timeout(@timeout, &)
|
|
336
|
+
rescue Timeout::Error
|
|
337
|
+
raise Core::RequestTimeout
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def invoke_handler(request)
|
|
341
|
+
return @handler.call(request.method_name, request.params) unless request.method_name == "rpc.discover"
|
|
342
|
+
|
|
343
|
+
build_openrpc_document
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def error_details(err)
|
|
347
|
+
case err
|
|
348
|
+
when Core::MethodNotFound
|
|
349
|
+
[Core::METHOD_NOT_FOUND, Core::ERROR_MESSAGES[Core::METHOD_NOT_FOUND], generic_data(err)]
|
|
350
|
+
when Core::ApplicationError, Core::ServerError then [err.code, err.message, err.rpc_data]
|
|
351
|
+
when Core::InvalidParams then [Core::INVALID_PARAMS, nil, generic_data(err, GENERIC_PARAMS_DATA)]
|
|
352
|
+
when Core::InvalidRequest then [Core::INVALID_REQUEST, nil, generic_data(err)]
|
|
353
|
+
else [Core::INTERNAL_ERROR, nil, generic_data(err)]
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def generic_data(err, fallback = GENERIC_ERROR_DATA) = @expose_errors ? err.message : fallback
|
|
358
|
+
end
|
|
359
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UltimateJsonRpc
|
|
4
|
+
module Transport
|
|
5
|
+
class Rack
|
|
6
|
+
CONTENT_TYPE = { "content-type" => "application/json" }.freeze
|
|
7
|
+
NOT_ALLOWED_BODY = '{"jsonrpc":"2.0","error":{"code":-32600,"message":"Method Not Allowed"},"id":null}'
|
|
8
|
+
NOT_ALLOWED_HEADERS = CONTENT_TYPE.merge("allow" => "POST").freeze
|
|
9
|
+
UNSUPPORTED_MEDIA_BODY = '{"jsonrpc":"2.0","error":{"code":-32600,"message":"Unsupported Media Type"},"id":null}'
|
|
10
|
+
|
|
11
|
+
def initialize(server)
|
|
12
|
+
@server = server
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
return [200, CONTENT_TYPE.dup, []] if env["REQUEST_METHOD"] == "HEAD"
|
|
17
|
+
return method_not_allowed unless env["REQUEST_METHOD"] == "POST"
|
|
18
|
+
return unsupported_media_type unless acceptable_content_type?(env)
|
|
19
|
+
|
|
20
|
+
body = env["rack.input"]&.read.to_s
|
|
21
|
+
response = @server.handle(body)
|
|
22
|
+
|
|
23
|
+
if response
|
|
24
|
+
[200, CONTENT_TYPE.dup, [response]]
|
|
25
|
+
else
|
|
26
|
+
[204, {}, []]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def freeze
|
|
31
|
+
@server.freeze
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def method_not_allowed
|
|
38
|
+
[405, NOT_ALLOWED_HEADERS.dup, [NOT_ALLOWED_BODY]]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def unsupported_media_type
|
|
42
|
+
[415, CONTENT_TYPE.dup, [UNSUPPORTED_MEDIA_BODY]]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def acceptable_content_type?(env)
|
|
46
|
+
ct = env["CONTENT_TYPE"]
|
|
47
|
+
ct.nil? || ct.empty? || ct.start_with?("application/json")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UltimateJsonRpc
|
|
4
|
+
module Transport
|
|
5
|
+
class Stdio
|
|
6
|
+
def initialize(server, input: $stdin, output: $stdout)
|
|
7
|
+
@server = server
|
|
8
|
+
@input = input
|
|
9
|
+
@output = output
|
|
10
|
+
@running = false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
@running = true
|
|
15
|
+
trap_signals
|
|
16
|
+
process_lines
|
|
17
|
+
ensure
|
|
18
|
+
@running = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def stop
|
|
22
|
+
@running = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def running? = @running
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def process_lines
|
|
30
|
+
@input.each_line do |line|
|
|
31
|
+
break unless @running
|
|
32
|
+
|
|
33
|
+
line = line.chomp
|
|
34
|
+
next if line.empty?
|
|
35
|
+
|
|
36
|
+
response = @server.handle(line)
|
|
37
|
+
next unless response
|
|
38
|
+
|
|
39
|
+
break unless write_response(response)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def write_response(response)
|
|
44
|
+
@output.puts(response)
|
|
45
|
+
@output.flush
|
|
46
|
+
true
|
|
47
|
+
rescue Errno::EPIPE, IOError
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def trap_signals
|
|
52
|
+
# Signal handlers set @running directly (no mutex). This is safe on MRI
|
|
53
|
+
# where boolean assignment is atomic. The process_lines loop checks
|
|
54
|
+
# @running between lines via each_line.
|
|
55
|
+
%w[INT TERM].each do |signal|
|
|
56
|
+
Signal.trap(signal) { @running = false }
|
|
57
|
+
rescue ArgumentError
|
|
58
|
+
# Signal not supported on this platform
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module UltimateJsonRpc
|
|
6
|
+
module Transport
|
|
7
|
+
# TCP adapter for UltimateJsonRpc servers.
|
|
8
|
+
# Thread-safety: each client gets its own thread. The @connection_count counter is protected
|
|
9
|
+
# by a Mutex. Under CRuby's GVL, Array/Hash reads are safe, but the Mutex ensures correctness
|
|
10
|
+
# on alternative Ruby implementations as well.
|
|
11
|
+
class TCP
|
|
12
|
+
DEFAULT_MAX_CONNECTIONS = 64
|
|
13
|
+
|
|
14
|
+
def initialize(server, port:, host: "127.0.0.1", max_connections: DEFAULT_MAX_CONNECTIONS)
|
|
15
|
+
@server = server
|
|
16
|
+
@host = host
|
|
17
|
+
@port = port
|
|
18
|
+
@max_connections = max_connections
|
|
19
|
+
@running = false
|
|
20
|
+
@tcp_server = nil
|
|
21
|
+
@connection_count = 0
|
|
22
|
+
@client_threads = []
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
MAX_LINE_BYTES = 4 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
def run
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
@running = true
|
|
31
|
+
@tcp_server = TCPServer.new(@host, @port)
|
|
32
|
+
end
|
|
33
|
+
trap_signals
|
|
34
|
+
accept_loop
|
|
35
|
+
ensure
|
|
36
|
+
@mutex.synchronize { @running = false }
|
|
37
|
+
close_server
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stop
|
|
41
|
+
@mutex.synchronize { @running = false }
|
|
42
|
+
close_server
|
|
43
|
+
threads = @mutex.synchronize { @client_threads.dup }
|
|
44
|
+
threads.each do |t|
|
|
45
|
+
t.join(5)
|
|
46
|
+
t.kill if t.alive?
|
|
47
|
+
end
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
@client_threads.clear
|
|
50
|
+
@connection_count = 0
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def running? = @mutex.synchronize { @running }
|
|
55
|
+
|
|
56
|
+
def port
|
|
57
|
+
server = @mutex.synchronize { @tcp_server }
|
|
58
|
+
server&.addr&.[](1)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def accept_loop
|
|
64
|
+
while (server = @mutex.synchronize { @running && @tcp_server })
|
|
65
|
+
next unless server.wait_readable(0.5)
|
|
66
|
+
|
|
67
|
+
client = accept_client(server)
|
|
68
|
+
next unless client
|
|
69
|
+
|
|
70
|
+
accept_or_reject(client)
|
|
71
|
+
end
|
|
72
|
+
rescue Errno::EBADF, IOError
|
|
73
|
+
# Server socket was closed (e.g., via stop)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def accept_client(server)
|
|
77
|
+
server.accept
|
|
78
|
+
rescue IOError, Errno::EBADF, Errno::EMFILE, Errno::ENFILE, Errno::ECONNABORTED
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def accept_or_reject(client)
|
|
83
|
+
if acquire_connection_slot
|
|
84
|
+
begin
|
|
85
|
+
thread = Thread.new(client) { |c| handle_client(c) }
|
|
86
|
+
@mutex.synchronize { @client_threads << thread }
|
|
87
|
+
rescue ThreadError
|
|
88
|
+
client.close rescue nil # rubocop:disable Style/RescueModifier
|
|
89
|
+
decrement_connections
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
client.close
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def acquire_connection_slot
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
return false if @connection_count >= @max_connections
|
|
99
|
+
|
|
100
|
+
@connection_count += 1
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def handle_client(client)
|
|
106
|
+
while (line = client.gets("\n", MAX_LINE_BYTES))
|
|
107
|
+
line = line.chomp
|
|
108
|
+
next if line.empty?
|
|
109
|
+
|
|
110
|
+
response = @server.handle(line)
|
|
111
|
+
next unless response
|
|
112
|
+
|
|
113
|
+
client.puts(response)
|
|
114
|
+
client.flush
|
|
115
|
+
end
|
|
116
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE
|
|
117
|
+
# Client disconnected
|
|
118
|
+
ensure
|
|
119
|
+
client.close unless client.closed?
|
|
120
|
+
decrement_connections
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def decrement_connections
|
|
124
|
+
@mutex.synchronize do
|
|
125
|
+
@connection_count -= 1
|
|
126
|
+
@client_threads.delete(Thread.current)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def close_server
|
|
131
|
+
server = @mutex.synchronize do
|
|
132
|
+
s = @tcp_server
|
|
133
|
+
@tcp_server = nil
|
|
134
|
+
s
|
|
135
|
+
end
|
|
136
|
+
server&.close
|
|
137
|
+
rescue IOError, Errno::EBADF
|
|
138
|
+
# Already closed
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def trap_signals
|
|
142
|
+
# Signal handlers set @running directly (no mutex). This is safe on MRI
|
|
143
|
+
# where boolean assignment is atomic. The accept_loop checks
|
|
144
|
+
# @running via mutex on each iteration, picking up the change.
|
|
145
|
+
%w[INT TERM].each do |signal|
|
|
146
|
+
Signal.trap(signal) { @running = false }
|
|
147
|
+
rescue ArgumentError
|
|
148
|
+
# Signal not supported on this platform
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UltimateJsonRpc
|
|
4
|
+
module Transport
|
|
5
|
+
class WebSocket
|
|
6
|
+
attr_reader :server
|
|
7
|
+
|
|
8
|
+
def initialize(server)
|
|
9
|
+
@server = server
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def on_message(data)
|
|
13
|
+
@server.handle(data.to_s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(_env, socket)
|
|
17
|
+
socket.on(:message) do |event|
|
|
18
|
+
response = on_message(event.respond_to?(:data) ? event.data : event)
|
|
19
|
+
socket.send(response) if response
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def freeze
|
|
24
|
+
@server.freeze
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "ultimate_json_rpc/version"
|
|
5
|
+
|
|
6
|
+
module UltimateJsonRpc
|
|
7
|
+
module Core
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require_relative "ultimate_json_rpc/core/errors"
|
|
13
|
+
require_relative "ultimate_json_rpc/core/request"
|
|
14
|
+
require_relative "ultimate_json_rpc/core/response"
|
|
15
|
+
require_relative "ultimate_json_rpc/core/handler"
|
|
16
|
+
require_relative "ultimate_json_rpc/server"
|