Class: RubyTestIDE::Server
- Inherits:
-
Object
- Object
- RubyTestIDE::Server
- Defined in:
- lib/ruby_test_ide/server.rb
Overview
-------------------------------------------------------------------- http
Constant Summary collapse
- PUBLIC =
File.join(ROOT, 'public')
- VENDOR =
File.join(PUBLIC, 'vendor')
- STATIC_CONTENT_TYPES =
{ '.js' => 'text/javascript; charset=utf-8', '.css' => 'text/css; charset=utf-8', '.ttf' => 'font/ttf', }.freeze
Instance Method Summary collapse
- #api_complete(req) ⇒ Object
- #api_diagnostics(req) ⇒ Object
- #api_hover(req) ⇒ Object
-
#api_learn ⇒ Object
Run the configured learn commands (any test framework or script) with the observer preloaded, then persist the merged type observations — completion/hover consult these when live eval can't help.
-
#api_read_file(params) ⇒ Object
---- endpoints.
- #api_run(req) ⇒ Object
- #api_save_file(req) ⇒ Object
-
#file_context(req) ⇒ Object
The file's real location, so the worker can chdir there and make require_relative resolve against the workspace.
- #handle_client(sock) ⇒ Object
-
#initialize(port) ⇒ Server
constructor
A new instance of Server.
- #json ⇒ Object
- #parse(body) ⇒ Object
- #route(verb, full_path, body, headers) ⇒ Object
-
#serve_static(path) ⇒ Object
Serves vendored front-end assets (Monaco) so the IDE works with no internet access beyond the initial
gem install. - #start ⇒ Object
- #unauthorized(html:) ⇒ Object
Constructor Details
#initialize(port) ⇒ Server
Returns a new instance of Server.
457 458 459 |
# File 'lib/ruby_test_ide/server.rb', line 457 def initialize(port) @port = port end |
Instance Method Details
#api_complete(req) ⇒ Object
621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 |
# File 'lib/ruby_test_ide/server.rb', line 621 def api_complete(req) code = req['code'].to_s lines = code.split("\n", -1) line = req['line'].to_i # 1-based (monaco convention) column = req['column'].to_i # 1-based before = (lines[line - 1] || '')[0, [column - 1, 0].max] receiver, prefix = RubyTestIDE.split_target(before.to_s) code_before = lines[0, line - 1].join("\n") result = RubyTestIDE.run_worker( { 'op' => 'complete', 'code_before' => code_before, 'receiver' => receiver, 'prefix' => prefix }.merge(file_context(req)), timeout: RUNNER_TIMEOUT ) result.merge('receiver' => receiver, 'prefix' => prefix) end |
#api_diagnostics(req) ⇒ Object
677 678 679 |
# File 'lib/ruby_test_ide/server.rb', line 677 def api_diagnostics(req) { 'diagnostics' => RubyTestIDE.syntax_diagnostics(req['code'].to_s) } end |
#api_hover(req) ⇒ Object
638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 |
# File 'lib/ruby_test_ide/server.rb', line 638 def api_hover(req) code = req['code'].to_s lines = code.split("\n", -1) line = req['line'].to_i word = req['word'].to_s word_start = req['wordStartColumn'].to_i before_word = (lines[line - 1] || '')[0, [word_start - 1, 0].max].to_s receiver = nil if before_word.end_with?('.') && !before_word.end_with?('..') start = RubyTestIDE.receiver_start(before_word, before_word.length - 1) receiver = before_word[start...-1] receiver = nil if receiver.to_s.strip.empty? end # For hover, context = everything up to and including the previous line, # plus the current line itself when it parses on its own. code_before = lines[0, line - 1].join("\n") result = RubyTestIDE.run_worker( { 'op' => 'hover', 'code_before' => code_before, 'receiver' => receiver, 'name' => word }.merge(file_context(req)), timeout: RUNNER_TIMEOUT ) if (src = result['source']) && src.start_with?("#{WORKSPACE}/") result['source_rel'] = src.delete_prefix("#{WORKSPACE}/") end if result['kind'] == 'method' result['doc'] = DOCS.lookup( "#{result['owner']}##{word}", "#{result['receiver_class']}##{word}", "#{result['owner']}.#{word}" ) elsif %w[variable constant class].include?(result['kind']) result['doc'] = DOCS.lookup(result['kind'] == 'class' ? result['name'] : result['class']) end result end |
#api_learn ⇒ Object
Run the configured learn commands (any test framework or script) with the observer preloaded, then persist the merged type observations — completion/hover consult these when live eval can't help.
689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 |
# File 'lib/ruby_test_ide/server.rb', line 689 def api_learn config = RubyTestIDE.load_config return { 'error' => config['config_error'] } if config['config_error'] commands = Array(config['learn'] || RubyTestIDE.default_learn_commands).map(&:to_s) if commands.empty? return { 'error' => 'nothing to run: add learn: commands to ' \ '.ruby_ide.yaml or create test/**/*_test.rb / spec files' } end timeout = (config['timeout'] || LEARN_TIMEOUT).to_i.clamp(1, 600) require 'tmpdir' require 'fileutils' merged = nil results = nil Dir.mktmpdir('ruby-ide-obs') do |obs_dir| results = commands.map { |cmd| RubyTestIDE.run_learn_command(cmd, obs_dir, timeout) } merged = RubyTestIDE.merge_observations(Dir.glob(File.join(obs_dir, 'obs-*.json'))) end unless merged.empty? FileUtils.mkdir_p(File.dirname(OBSERVATIONS)) File.write(OBSERVATIONS, JSON.generate(merged)) end { 'commands' => results, 'passed' => results.all? { |r| r['ok'] }, 'methods_observed' => merged.size } end |
#api_read_file(params) ⇒ Object
---- endpoints
597 598 599 600 601 602 |
# File 'lib/ruby_test_ide/server.rb', line 597 def api_read_file(params) abs = RubyTestIDE.workspace_path(params['path']) raise Errno::ENOENT, params['path'] unless File.file?(abs) { 'path' => params['path'], 'content' => File.read(abs) } end |
#api_run(req) ⇒ Object
681 682 683 684 |
# File 'lib/ruby_test_ide/server.rb', line 681 def api_run(req) RubyTestIDE.run_worker({ 'op' => 'run', 'code' => req['code'].to_s }.merge(file_context(req)), timeout: RUNNER_TIMEOUT) end |
#api_save_file(req) ⇒ Object
604 605 606 607 608 609 610 |
# File 'lib/ruby_test_ide/server.rb', line 604 def api_save_file(req) abs = RubyTestIDE.workspace_path(req['path']) require 'fileutils' FileUtils.mkdir_p(File.dirname(abs)) File.write(abs, req['content'].to_s) { 'ok' => true, 'path' => req['path'], 'size' => File.size(abs) } end |
#file_context(req) ⇒ Object
The file's real location, so the worker can chdir there and make require_relative resolve against the workspace.
614 615 616 617 618 619 |
# File 'lib/ruby_test_ide/server.rb', line 614 def file_context(req) abs = RubyTestIDE.workspace_path(req['path']) { 'path' => abs, 'dir' => File.dirname(abs), 'root' => WORKSPACE } rescue ArgumentError {} end |
#handle_client(sock) ⇒ Object
489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 |
# File 'lib/ruby_test_ide/server.rb', line 489 def handle_client(sock) request_line = sock.gets return unless request_line verb, path, = request_line.split headers = {} while (line = sock.gets) && line != "\r\n" k, v = line.split(': ', 2) headers[k.to_s.downcase] = v.to_s.strip end body = headers['content-length'] ? sock.read(headers['content-length'].to_i) : '' status, ctype, payload, cache = route(verb, path, body, headers) sock.write("HTTP/1.1 #{status}\r\n" \ "Content-Type: #{ctype}\r\n" \ "Content-Length: #{payload.bytesize}\r\n" \ "Cache-Control: #{cache || 'no-store'}\r\n" \ "Connection: close\r\n\r\n") sock.write(payload) end |
#json ⇒ Object
587 588 589 590 591 592 593 |
# File 'lib/ruby_test_ide/server.rb', line 587 def json payload = yield ['200 OK', 'application/json; charset=utf-8', JSON.generate(payload)] rescue StandardError => e ['500 Internal Server Error', 'application/json', JSON.generate('error' => "#{e.class}: #{e.}")] end |
#parse(body) ⇒ Object
568 569 570 571 572 |
# File 'lib/ruby_test_ide/server.rb', line 568 def parse(body) JSON.parse(body.to_s) rescue StandardError {} end |
#route(verb, full_path, body, headers) ⇒ Object
510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 |
# File 'lib/ruby_test_ide/server.rb', line 510 def route(verb, full_path, body, headers) path, query = full_path.to_s.split('?', 2) params = query ? URI.decode_www_form(query).to_h : {} # /health (used by process supervisors / readiness probes) and # /vendor/* (the vendored Monaco library — no user data) never need # the token. Everything else does: this server evaluates arbitrary # code and reads/writes the workspace on request. case [verb, path] in ['GET', '/health'] return ['200 OK', 'application/json', '{"ok":true}'] in ['GET', %r{\A/vendor/}] return serve_static(path) else # falls through to the auth-gated routes below end token = headers['x-ruby-test-ide-token'] || params['token'] return (html: path == '/' || path == '/index.html') unless RubyTestIDE.token_matches?(token) case [verb, path] in ['GET', '/'] | ['GET', '/index.html'] # Cache-bust vendored assets with the gem version: serve_static sets # a 1-year immutable cache, so a stale browser cache across a gem # upgrade would otherwise serve last version's Monaco forever. The # token is only embedded here because we've just verified it above — # the client then sends it back as a header on every /api/* call. html = File.read(File.join(PUBLIC, 'index.html')) .gsub('__RTI_VERSION__', RubyTestIDE::VERSION) .gsub('__RTI_TOKEN__', RubyTestIDE::TOKEN) ['200 OK', 'text/html; charset=utf-8', html] in ['GET', '/api/files'] then json { { 'files' => RubyTestIDE.workspace_files } } in ['GET', '/api/file'] then json { api_read_file(params) } in ['POST', '/api/file'] then json { api_save_file(parse(body)) } in ['POST', '/api/complete'] then json { api_complete(parse(body)) } in ['POST', '/api/hover'] then json { api_hover(parse(body)) } in ['POST', '/api/diagnostics'] then json { api_diagnostics(parse(body)) } in ['POST', '/api/run'] then json { api_run(parse(body)) } in ['POST', '/api/learn'] then json { api_learn } else ['404 Not Found', 'text/plain', 'not found'] end rescue Errno::ENOENT ['404 Not Found', 'text/plain', 'not found'] end |
#serve_static(path) ⇒ Object
Serves vendored front-end assets (Monaco) so the IDE works with no
internet access beyond the initial gem install. Content is fixed at
gem-build time and keyed by RubyTestIDE::VERSION in the URL the
browser requests, so it's safe to cache indefinitely.
578 579 580 581 582 583 584 585 |
# File 'lib/ruby_test_ide/server.rb', line 578 def serve_static(path) rel = path.delete_prefix('/vendor/') abs = File.(File.join(VENDOR, rel)) return ['404 Not Found', 'text/plain', 'not found'] unless abs.start_with?("#{VENDOR}/") && File.file?(abs) ctype = STATIC_CONTENT_TYPES[File.extname(abs)] || 'application/octet-stream' ['200 OK', ctype, File.binread(abs), 'public, max-age=31536000, immutable'] end |
#start ⇒ Object
461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 |
# File 'lib/ruby_test_ide/server.rb', line 461 def start server = TCPServer.new('127.0.0.1', @port) url = "http://localhost:#{@port}/" url += "?token=#{RubyTestIDE::TOKEN}" unless RubyTestIDE::AUTH_DISABLED puts "RubyTestIDE listening on #{url} (ruby #{RUBY_VERSION})" # This line is the only way to learn the token when stdout is # redirected to a file — puts alone can sit in a buffer indefinitely # on a long-running server that never exits on its own. $stdout.flush if RubyTestIDE::AUTH_DISABLED warn 'WARNING: started with --no-auth — anyone who can reach this port can execute code as you.' end loop do client = server.accept Thread.new(client) do |sock| handle_client(sock) rescue StandardError => e warn "client error: #{e.class}: #{e.}" ensure begin sock.close rescue StandardError nil end end end end |
#unauthorized(html:) ⇒ Object
556 557 558 559 560 561 562 563 564 565 566 |
# File 'lib/ruby_test_ide/server.rb', line 556 def (html:) if html ['401 Unauthorized', 'text/plain; charset=utf-8', "401 Unauthorized\n\n" \ "Missing or invalid token. Open the URL printed in the terminal " \ "where you ran ruby-test-ide (it includes ?token=...).\n"] else ['401 Unauthorized', 'application/json; charset=utf-8', JSON.generate('error' => 'unauthorized: missing or invalid token')] end end |