diff --git a/v2/ruby/lib/terminalwire/v2/rails.rb b/v2/ruby/lib/terminalwire/v2/rails.rb index cfd248f..68cacf6 100644 --- a/v2/ruby/lib/terminalwire/v2/rails.rb +++ b/v2/ruby/lib/terminalwire/v2/rails.rb @@ -52,6 +52,28 @@ def self.dual_terminal(cli, v1: nil, v2: nil) ) end + # The v2-DEFAULT endpoint: serve `cli` over v2, with no v1. Mount it the same way + # as dual_terminal: + # + # match "/terminal", to: Terminalwire::V2::Rails.terminal(MainTerminal), + # via: [:get, :connect] + # + # It returns a version endpoint (not the bare Rack): a connection advertising the + # `terminalwire.v2` subprotocol — and any connection that doesn't ask for another + # version — is served by the v2 server. The endpoint is the forward-compatible + # seam: a future v3 registers another handler here without changing the app's + # route. (A bare Rack handed to `match to:` drops streaming output in production; + # the endpoint, like dual_terminal's, is what Rails routing needs.) + def self.terminal(cli, verbose: nil, report: nil) + Terminalwire::V2::Server.dualize(cli) + v2 = Terminalwire::V2::Server::Rack.new( + cli, + verbose: verbose.nil? ? verbose?() : verbose, + report: report || self.report + ) + VersionEndpoint.new(default: v2, by_subprotocol: { SUBPROTOCOL => v2 }) + end + # In dev/test, show the full backtrace to the client (consider_all_requests_local, # like v1). In production the client sees the generic message — but the real # exception is still LOGGED + reported (below), never silently swallowed. @@ -97,6 +119,23 @@ def call(env) (protos.include?(SUBPROTOCOL) ? @v2 : @v1).call(env) end end + + # Routes a WebSocket upgrade to a handler by the version subprotocol it advertises, + # falling back to `default` (v2) when none matches — the forward-compatible seam for + # registering future protocol versions. Same Rack-endpoint shape as Dispatcher, so + # Rails `match to:` hands the connection off correctly in production. + class VersionEndpoint + def initialize(default:, by_subprotocol: {}) + @default = default + @by_subprotocol = by_subprotocol + end + + def call(env) + protos = env["HTTP_SEC_WEBSOCKET_PROTOCOL"].to_s.split(/,\s*/) + handler = protos.lazy.filter_map { |proto| @by_subprotocol[proto] }.first || @default + handler.call(env) + end + end end end end diff --git a/v2/ruby/spec/server/rails_terminal_spec.rb b/v2/ruby/spec/server/rails_terminal_spec.rb new file mode 100644 index 0000000..11baad7 --- /dev/null +++ b/v2/ruby/spec/server/rails_terminal_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "spec_helper" +require "terminalwire/v2/rails" + +RSpec.describe Terminalwire::V2::Rails do + describe Terminalwire::V2::Rails::VersionEndpoint do + let(:v2) { ->(env) { [:v2, env] } } + let(:v3) { ->(env) { [:v3, env] } } + + subject(:endpoint) do + described_class.new(default: v2, by_subprotocol: { "terminalwire.v2" => v2, "terminalwire.v3" => v3 }) + end + + def env(protocols) + { "HTTP_SEC_WEBSOCKET_PROTOCOL" => protocols } + end + + it "routes an advertised version subprotocol to its handler" do + expect(endpoint.call(env("terminalwire.v2")).first).to eq :v2 + expect(endpoint.call(env("terminalwire.v3")).first).to eq :v3 + end + + it "defaults (to v2) when no known version is advertised" do + expect(endpoint.call(env(nil)).first).to eq :v2 + expect(endpoint.call(env("")).first).to eq :v2 + expect(endpoint.call(env("ws, made-up")).first).to eq :v2 + end + + it "picks the first matching version among several offered" do + expect(endpoint.call(env("ws, terminalwire.v3")).first).to eq :v3 + end + end + + describe ".terminal" do + let(:cli) { Class.new(Thor) } + + it "returns a v2-default VersionEndpoint" do + expect(described_class.terminal(cli)).to be_a(Terminalwire::V2::Rails::VersionEndpoint) + end + + it "routes the v2 subprotocol — and anything unversioned — to a Server::Rack" do + endpoint = described_class.terminal(cli) + rack = endpoint.instance_variable_get(:@default) + expect(rack).to be_a(Terminalwire::V2::Server::Rack) + # the v2 subprotocol maps to the same Rack as the default + expect(endpoint.instance_variable_get(:@by_subprotocol)["terminalwire.v2"]).to be(rack) + end + + it "dualizes the cli so it answers the v2 wire" do + described_class.terminal(cli) + expect(cli.include?(Terminalwire::V2::Server::DualThor)).to be(true) + end + end +end