protocol-htty 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c222bcf565b63477735114b057e88c45508674629d78646c0fa73ab34b1abbd9
4
- data.tar.gz: fd7405a11f04ee961c218473951ef8f0051dde6f0c581c2ecc129a87f9fbfe37
3
+ metadata.gz: ba295c47e02345ba7101c3958786fc21fd7187294abe6fef0f39a6677baf9be3
4
+ data.tar.gz: 2fcb179db305375b86b5196ed149c0ef1eed4fb52c7d06447b0a5e68d4dd16a4
5
5
  SHA512:
6
- metadata.gz: 0cf629cde9cb0d76603be89e9290335087a540c92be10e0f433732e6f323306ec8ddd7ba5cbe104ea0b7ef9ac57983bd3b0017005742c4065af1ecceebeca34d
7
- data.tar.gz: db74e7230fdd22dbc18f1f4e85526856d3ebdd9eb550c1f7c3fdd6c3e84fcfc45d9ccb97559f813c9985d39d693e0ba2ef21d28a337086f94f76367c8de6dbfc
6
+ metadata.gz: 4132b7c507a0b285f772c91bca4ddbcb315d5faccfeafd28c6907df4553ad5a20190299e9d924c75a946819e656205b603bcae69b6bc61d1e20b08f5e437f60f
7
+ data.tar.gz: 3149badd26c592217168140c69b654190fdad74d6884b9ef4e729ff9e8a1f4b342f284bec1b1759698d53a15c4c11858e666654d6b7de0cc47e07f44854d45bd
checksums.yaml.gz.sig CHANGED
Binary file
@@ -7,86 +7,100 @@ require "io/stream"
7
7
 
8
8
  module Protocol
9
9
  module HTTY
10
- # Transport an opaque byte stream over HTTY chunks.
10
+ # Transport an opaque byte stream after the HTTY bootstrap handshake.
11
11
  class Stream
12
- # Since base64 encoding adds 33% overhead, we can fit 3KB of binary data into a single HTTY chunk without exceeding the typical MTU of 4KB:
13
- PACKET_SIZE = 1024*3
14
-
15
- # Create a stream on top of HTTY framed input and output.
16
- # @parameter input [IO | IO::Stream] The source of framed HTTY chunks.
17
- # @parameter output [IO | IO::Stream | Nil] The sink for framed HTTY chunks.
18
- # @parameter packet_size [Integer] The maximum payload size for each chunk.
19
- def initialize(input, output = input, packet_size: PACKET_SIZE)
20
- @framer = Framer.new(::IO::Stream(input), ::IO::Stream(output))
21
- @packet_size = packet_size
22
- @buffer = +"".b
12
+ ESC = "\e"
13
+ DCS = "#{ESC}P"
14
+ ST = "#{ESC}\\"
15
+ BOOTSTRAP_PREFIX = "+H"
16
+ RAW_MODE = "raw"
17
+
18
+ def self.open(stream, bootstrap: nil, mode: RAW_MODE)
19
+ stream = self.new(::IO::Stream(stream))
20
+
21
+ case bootstrap
22
+ when :write
23
+ stream.write_bootstrap(mode)
24
+ when :read
25
+ actual_mode = stream.read_bootstrap
26
+
27
+ unless actual_mode == mode
28
+ raise ProtocolError, "Expected HTTY bootstrap mode #{mode.inspect}, got #{actual_mode.inspect}"
29
+ end
30
+ end
31
+
32
+ return stream
33
+ end
34
+
35
+ # Create a stream on top of a raw byte-preserving transport.
36
+ # @parameter stream [IO::Stream] The duplex byte stream used after bootstrap.
37
+ def initialize(stream)
38
+ @stream = stream
23
39
  @local_closed = false
24
- @remote_closed = false
25
40
  end
26
41
 
27
- attr :framer
42
+ attr :stream
28
43
 
29
- # Return the writable IO object used by the underlying framer.
30
- # @returns [IO | IO::Stream] The output side of the framed transport.
44
+ # Return the underlying duplex stream.
31
45
  def io
32
- @framer.output
46
+ @stream
47
+ end
48
+
49
+ def write_bootstrap(mode = RAW_MODE)
50
+ @stream.write("#{DCS}#{BOOTSTRAP_PREFIX}#{mode}#{ST}")
51
+ @stream.flush
52
+ end
53
+
54
+ def read_bootstrap
55
+ while payload = read_payload
56
+ next unless payload.start_with?(BOOTSTRAP_PREFIX)
57
+ mode = payload.delete_prefix(BOOTSTRAP_PREFIX)
58
+
59
+ unless mode == RAW_MODE
60
+ raise ProtocolError, "Unsupported HTTY bootstrap mode: #{mode.inspect}"
61
+ end
62
+
63
+ return mode
64
+ end
65
+
66
+ return nil
33
67
  end
34
68
 
35
69
  # Read application bytes from the HTTY transport.
36
- # @parameter length [Integer | Nil] The exact number of bytes to read, or `nil` for all buffered bytes.
37
- # @returns [String | Nil] The requested bytes, an empty binary string for `0`, or `nil` if more data is required or the remote side is closed.
38
70
  def read(length = nil)
39
71
  return +"".b if length == 0
40
-
41
- fill(length)
42
-
43
- return nil if @buffer.empty? && @remote_closed
44
- return nil if @buffer.empty?
45
- return nil if length && @buffer.bytesize < length && !@remote_closed
46
-
47
- if length
48
- return @buffer.slice!(0, length)
49
- else
50
- return @buffer.slice!(0, @buffer.bytesize)
51
- end
72
+ return @stream.read(length)
52
73
  end
53
74
 
54
- # Write application bytes as one or more HTTY chunks.
55
- # @parameter data [String | Array(Integer)] The opaque bytes to send.
75
+ # Write application bytes after bootstrap.
56
76
  # @returns [self]
57
77
  # @raises [IOError] If the local side of the transport is closed.
58
78
  def write(data)
59
79
  raise IOError, "HTTY stream is closed for writing!" if @local_closed
60
80
 
61
- data = data.to_s.b
62
-
63
- until data.empty?
64
- chunk = data.byteslice(0, @packet_size)
65
- @framer.write_chunk(chunk)
66
- data = data.byteslice(chunk.bytesize..)
67
- end
68
-
69
- @framer.flush
81
+ @stream.write(data.to_s.b)
70
82
 
71
83
  return self
72
84
  end
73
85
 
74
- # Flush any buffered output through the underlying framer.
86
+ # Flush any buffered output through the underlying stream.
75
87
  # @returns [void]
76
88
  def flush
77
- @framer.flush
89
+ @stream.flush
78
90
  end
79
91
 
80
92
  # Close the local write side of this stream abstraction.
81
93
  # HTTY does not define a close packet, and closing this object does not close the underlying terminal IO.
82
94
  # @returns [void]
83
- def close
95
+ def close_write(error = nil)
84
96
  unless @local_closed
85
97
  @local_closed = true
86
- @framer.flush
98
+ @stream.flush
87
99
  end
88
100
  end
89
101
 
102
+ alias close close_write
103
+
90
104
  # Check whether the local side of the transport is closed.
91
105
  # @returns [bool] True if local writes have been closed.
92
106
  def closed?
@@ -96,28 +110,41 @@ module Protocol
96
110
  # Check whether the remote side may still provide more data.
97
111
  # @returns [bool] True if the remote side has not sent or implied a close.
98
112
  def readable?
99
- !@remote_closed
113
+ !@stream.closed?
100
114
  end
101
115
 
102
116
  private
103
117
 
104
- def fill(length)
105
- while needs_more_data?(length)
106
- chunk = @framer.read_chunk
118
+ def read_payload
119
+ while prefix = @stream.read(1)
120
+ next unless prefix == ESC
107
121
 
108
- unless chunk
109
- @remote_closed = true
110
- break
111
- end
112
- @buffer << chunk
122
+ marker = @stream.read(1)
123
+ return nil unless marker
124
+ next unless marker == "P"
125
+
126
+ return consume_packet
113
127
  end
128
+
129
+ return nil
114
130
  end
115
131
 
116
- def needs_more_data?(length)
117
- return false if @remote_closed
118
- return @buffer.empty? unless length
132
+ def consume_packet
133
+ buffer = +""
134
+
135
+ while chunk = @stream.read(1)
136
+ if chunk == ESC
137
+ terminator = @stream.read(1)
138
+ return buffer if terminator == "\\"
139
+
140
+ buffer << chunk
141
+ buffer << terminator if terminator
142
+ else
143
+ buffer << chunk
144
+ end
145
+ end
119
146
 
120
- @buffer.bytesize < length
147
+ raise EOFError, "Incomplete HTTY chunk!"
121
148
  end
122
149
  end
123
150
  end
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Protocol
7
7
  module HTTY
8
- VERSION = "0.1.0"
8
+ VERSION = "0.2.0"
9
9
  end
10
10
  end
data/lib/protocol/htty.rb CHANGED
@@ -5,5 +5,4 @@
5
5
 
6
6
  require_relative "htty/version"
7
7
  require_relative "htty/error"
8
- require_relative "htty/framer"
9
8
  require_relative "htty/stream"
data/readme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Protocol::HTTY
2
2
 
3
- `protocol-htty` defines a small, terminal-safe framing layer for carrying an opaque byte stream over TTY side channels.
3
+ `protocol-htty` defines a small, terminal-safe bootstrap for carrying a raw HTTP/2 byte stream over terminal-attached sessions.
4
4
 
5
5
  [![Development Status](https://github.com/socketry/protocol-htty/workflows/Test/badge.svg)](https://github.com/socketry/protocol-htty/actions?workflow=Test)
6
6
 
@@ -10,34 +10,41 @@ Traditional terminal user interfaces are useful, but they are also a poor fit fo
10
10
 
11
11
  In practice, this means TUIs often force applications into compromises: text-heavy layouts, ad-hoc protocols, and bespoke escape-sequence behavior that is hard to standardise across runtimes and terminals.
12
12
 
13
- HTTY exists to keep the portability and deployment advantages of terminal workflows while avoiding the need to build an entire application model out of terminal control codes. Instead of asking the terminal stream itself to represent higher-level UI state, HTTY provides a small framing layer that can carry a normal plaintext HTTP/2 connection alongside terminal traffic, enabling applications to attach browser surfaces to a normal terminal session over HTTY.
13
+ HTTY exists to keep the portability and deployment advantages of terminal workflows while avoiding the need to build an entire application model out of terminal control codes. Instead of asking the terminal stream itself to represent higher-level UI state, HTTY provides a small bootstrap that can hand a terminal session over to a normal plaintext HTTP/2 connection, enabling applications to attach browser surfaces to a normal terminal session over HTTY.
14
14
 
15
15
  ## Design
16
16
 
17
- HTTY does not model application requests, regions, or resources. It transports the two directions of a single plaintext HTTP/2 (`h2c`) connection over terminal-safe chunks without introducing a second session protocol.
17
+ HTTY does not model application requests, regions, or resources. It transports the two directions of a single plaintext HTTP/2 (`h2c`) connection over a raw terminal-attached byte stream without introducing a second session protocol.
18
18
 
19
- Each chunk is encoded as a DCS sequence:
19
+ HTTY v1 begins with one DCS bootstrap sequence:
20
20
 
21
21
  ``` text
22
- ESC P HTTY;1;BASE64_CHUNK ESC \
22
+ ESC P + H raw ESC \
23
23
  ```
24
24
 
25
- The framing layer intentionally stays small so it can be reimplemented in other runtimes.
25
+ After that bootstrap has been consumed, the session carries plain `h2c` bytes. The takeover layer intentionally stays small so it can be reimplemented in other runtimes.
26
26
 
27
27
  ## Usage
28
28
 
29
29
  Please see the [project documentation](https://socketry.github.io/protocol-htty/) for more details.
30
30
 
31
- - [Getting Started](https://socketry.github.io/protocol-htty/guides/getting-started/index) - This guide explains how to get started with `protocol-htty` for terminal-safe HTTP/2 byte stream transport.
31
+ - [Getting Started](https://socketry.github.io/protocol-htty/guides/getting-started/index) - This guide explains how to get started with `protocol-htty` for DCS-bootstrapped raw HTTP/2 byte stream transport.
32
32
 
33
- - [HTTY Specification](https://socketry.github.io/protocol-htty/guides/specification/index) - This document specifies HTTY as a terminal-safe framing layer for carrying a plaintext HTTP/2 (`h2c`) byte stream over terminal side channels.
33
+ - [HTTY Specification](https://socketry.github.io/protocol-htty/guides/specification/index) - This document specifies HTTY as a DCS-bootstrapped raw-mode takeover transport for carrying a plaintext HTTP/2 (`h2c`) connection over terminal-attached sessions.
34
34
 
35
35
  ## Releases
36
36
 
37
37
  Please see the [project releases](https://socketry.github.io/protocol-htty/releases/index) for all releases.
38
38
 
39
+ ### v0.2.0
40
+
41
+ - Add `Protocol::HTTY::Stream.open(stream, **options)` as the preferred constructor for bootstrapping HTTY over an existing stream.
42
+ - Inline HTTY bootstrap encoding and decoding into `Protocol::HTTY::Stream`, keeping the public API focused on the single raw byte-stream abstraction used by HTTP/2.
43
+
39
44
  ### v0.1.0
40
45
 
46
+ - Initial release.
47
+
41
48
  ## Contributing
42
49
 
43
50
  We welcome contributions to this project.
data/releases.md CHANGED
@@ -1,3 +1,10 @@
1
1
  # Releases
2
2
 
3
+ ## v0.2.0
4
+
5
+ - Add `Protocol::HTTY::Stream.open(stream, **options)` as the preferred constructor for bootstrapping HTTY over an existing stream.
6
+ - Inline HTTY bootstrap encoding and decoding into `Protocol::HTTY::Stream`, keeping the public API focused on the single raw byte-stream abstraction used by HTTP/2.
7
+
3
8
  ## v0.1.0
9
+
10
+ - Initial release.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "stringio"
7
+ require "protocol/htty"
8
+
9
+ class OneByteInput
10
+ def initialize(data)
11
+ @data = data.b
12
+ @offset = 0
13
+ end
14
+
15
+ def read(length = nil)
16
+ return nil if @offset >= @data.bytesize
17
+
18
+ length = [length || @data.bytesize, 1].min
19
+ chunk = @data.byteslice(@offset, length)
20
+ @offset += chunk.bytesize
21
+
22
+ return chunk
23
+ end
24
+ end
25
+
26
+ describe Protocol::HTTY::Stream do
27
+ let(:input) {StringIO.new}
28
+ let(:output) {StringIO.new}
29
+ let(:io) {IO::Stream::Duplex(input, output)}
30
+ let(:stream) {subject.new(io)}
31
+
32
+ it "writes the HTTY raw bootstrap" do
33
+ stream.write_bootstrap
34
+
35
+ expect(output.string).to be == "\eP+Hraw\e\\"
36
+ end
37
+
38
+ it "reads the HTTY raw bootstrap" do
39
+ input.string = "hello\eP+Hraw\e\\world"
40
+ input.rewind
41
+
42
+ mode = stream.read_bootstrap
43
+
44
+ expect(mode).to be == "raw"
45
+ end
46
+
47
+ it "reads a bootstrap split across one-byte reads" do
48
+ stream = subject.new(OneByteInput.new("hello\eP+Hraw\e\\"))
49
+
50
+ expect(stream.read_bootstrap).to be == "raw"
51
+ end
52
+
53
+ it "preserves bytes after the HTTY bootstrap" do
54
+ input.string = "\eP+Hraw\e\\world"
55
+ input.rewind
56
+
57
+ stream.read_bootstrap
58
+
59
+ expect(io.read(5)).to be == "world"
60
+ end
61
+
62
+ it "raises on unsupported bootstrap modes" do
63
+ input.string = "\eP+Hframed\e\\"
64
+ input.rewind
65
+
66
+ expect do
67
+ stream.read_bootstrap
68
+ end.to raise_exception(Protocol::HTTY::ProtocolError)
69
+ end
70
+
71
+ it "raises on incomplete bootstraps" do
72
+ input.string = "\eP+Hraw"
73
+ input.rewind
74
+
75
+ expect do
76
+ stream.read_bootstrap
77
+ end.to raise_exception(EOFError)
78
+ end
79
+
80
+ it "ignores unrelated DCS payloads before the bootstrap" do
81
+ input.string = "\ePfoo\e\\\eP+Hraw\e\\"
82
+ input.rewind
83
+
84
+ expect(stream.read_bootstrap).to be == "raw"
85
+ end
86
+
87
+ it "ignores implementation-specific reset markers before the bootstrap" do
88
+ input.string = "\eP+reset:token\e\\\eP+Hraw\e\\"
89
+ input.rewind
90
+
91
+ expect(stream.read_bootstrap).to be == "raw"
92
+ end
93
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "open3"
7
+ require "pty"
8
+ require "rbconfig"
9
+ require "timeout"
10
+ require "protocol/http2"
11
+ require "protocol/http2/client"
12
+ require "protocol/http2/stream"
13
+ require "protocol/htty"
14
+ require "protocol/htty/fixtures"
15
+
16
+ class PTYStream
17
+ def initialize(input, output)
18
+ @input = input
19
+ @output = output
20
+ @buffer = +"".b
21
+ end
22
+
23
+ def read(length)
24
+ while @buffer.bytesize < length
25
+ @buffer << @input.readpartial(4096).b
26
+ end
27
+
28
+ data = @buffer.byteslice(0, length)
29
+ @buffer = @buffer.byteslice(length, @buffer.bytesize) || +"".b
30
+
31
+ return data
32
+ rescue EOFError, Errno::EIO
33
+ raise EOFError, "PTY closed"
34
+ end
35
+
36
+ def write(data)
37
+ @output.write(data)
38
+ end
39
+
40
+ def flush
41
+ @output.flush
42
+ end
43
+
44
+ def close
45
+ @input.close rescue nil
46
+ @output.close rescue nil
47
+ end
48
+
49
+ def closed?
50
+ @input.closed?
51
+ end
52
+ end
53
+
54
+ class ResponseStream < Protocol::HTTP2::Stream
55
+ attr :response_headers
56
+ attr :body
57
+
58
+ def initialize(...)
59
+ super
60
+ @response_headers = []
61
+ @body = +"".b
62
+ end
63
+
64
+ def process_headers(frame)
65
+ @response_headers = super
66
+ end
67
+
68
+ def process_data(frame)
69
+ data = super
70
+ @body << data.b if data
71
+ return data
72
+ end
73
+ end
74
+
75
+ class Client < Protocol::HTTP2::Client
76
+ def create_stream(id = next_stream_id)
77
+ ResponseStream.create(self, id)
78
+ end
79
+ end
80
+
81
+ describe "HTTY over a real PTY" do
82
+ let(:root) {File.expand_path("../../..", __dir__)}
83
+ let(:ruby_load_path) {File.join(root, "lib")}
84
+
85
+ def spawn_fixture(name)
86
+ environment = {"RUBYLIB" => ruby_load_path}
87
+
88
+ PTY.spawn(environment, RbConfig.ruby, Protocol::HTTY::Fixtures.executable_path(name))
89
+ end
90
+
91
+ def with_fixture(name)
92
+ input, output, pid = spawn_fixture(name)
93
+ input.binmode
94
+ output.binmode
95
+
96
+ stream = PTYStream.new(input, output)
97
+
98
+ yield stream
99
+ ensure
100
+ stream&.close
101
+ Process.wait(pid) rescue nil
102
+ end
103
+
104
+ it "ignores terminal noise before the bootstrap" do
105
+ Timeout.timeout(5) do
106
+ with_fixture("bootstrap") do |stream|
107
+ framer = Protocol::HTTY::Stream.new(stream)
108
+
109
+ expect(framer.read_bootstrap).to be == "raw"
110
+ expect(stream.read(3)).to be == "RAW"
111
+ end
112
+ end
113
+ end
114
+
115
+ it "delivers the HTTP/2 connection preface after raw takeover" do
116
+ Timeout.timeout(5) do
117
+ with_fixture("raw_preface") do |stream|
118
+ framer = Protocol::HTTY::Stream.new(stream)
119
+
120
+ expect(framer.read_bootstrap).to be == "raw"
121
+
122
+ stream.write(Protocol::HTTP2::CONNECTION_PREFACE)
123
+ stream.flush
124
+
125
+ expect(stream.read(10)).to be == "PREFACE_OK"
126
+ end
127
+ end
128
+ end
129
+
130
+ it "runs an HTTP/2 session until command-side GOAWAY" do
131
+ Timeout.timeout(5) do
132
+ with_fixture("http2_server") do |stream|
133
+ Protocol::HTTY::Stream.new(stream).read_bootstrap
134
+
135
+ framer = Protocol::HTTP2::Framer.new(stream)
136
+ client = Client.new(framer)
137
+ client.send_connection_preface
138
+
139
+ request = client.create_stream
140
+ request.send_headers(
141
+ [[":method", "GET"], [":path", "/"], [":scheme", "http"], [":authority", "htty.local"]],
142
+ Protocol::HTTP2::END_STREAM
143
+ )
144
+
145
+ goaway = false
146
+
147
+ until goaway
148
+ begin
149
+ frame = client.read_frame
150
+ goaway = frame.is_a?(Protocol::HTTP2::GoawayFrame)
151
+ rescue Protocol::HTTP2::GoawayError
152
+ goaway = true
153
+ end
154
+ end
155
+
156
+ expect(request.response_headers.to_h[":status"]).to be == "200"
157
+ expect(request.body).to be == "OK\n"
158
+ end
159
+ end
160
+ end
161
+
162
+ it "treats command exit after bootstrap without GOAWAY as an abort" do
163
+ Timeout.timeout(5) do
164
+ with_fixture("abort_after_bootstrap") do |stream|
165
+ expect(Protocol::HTTY::Stream.new(stream).read_bootstrap).to be == "raw"
166
+
167
+ framer = Protocol::HTTP2::Framer.new(stream)
168
+
169
+ expect do
170
+ framer.read_frame
171
+ end.to raise_exception(EOFError)
172
+ end
173
+ end
174
+ end
175
+
176
+ it "recovers the surrounding shell after an aborted session" do
177
+ client = File.join(root, "examples/pty/client.rb")
178
+
179
+ output, error, status = Timeout.timeout(10) do
180
+ Open3.capture3(
181
+ {"HTTY_SHELL" => "bash", "HTTY_FAIL_ON" => "5"},
182
+ RbConfig.ruby,
183
+ client,
184
+ chdir: root
185
+ )
186
+ end
187
+
188
+ combined_output = "#{output}#{error}"
189
+
190
+ expect(status).to be(:success?)
191
+ expect(combined_output).to be(:include?, "[htty] session 5 failed")
192
+ expect(combined_output).to be(:include?, "[htty] bootstrap detected \u2013 session 6")
193
+ expect(combined_output).to be(:include?, "session 10 \u2013 status: 200")
194
+ end
195
+ end
@@ -10,41 +10,49 @@ require "protocol/htty"
10
10
 
11
11
  describe Protocol::HTTY::Stream do
12
12
  let(:writer) {StringIO.new}
13
- let(:stream) {subject.new(StringIO.new, writer, packet_size: 8)}
13
+ let(:stream) {subject.open(IO::Stream::Duplex(StringIO.new, writer))}
14
14
 
15
- it "chunks opaque payload into HTTY chunks" do
15
+ it "writes raw bytes after bootstrap" do
16
+ stream.write_bootstrap
16
17
  stream.write(Protocol::HTTP2::CONNECTION_PREFACE)
18
+ stream.flush
17
19
 
18
- expect(writer.string.scan(/\ePHTTY;1;/).size).to be > 1
20
+ expect(writer.string).to be == "\eP+Hraw\e\\#{Protocol::HTTP2::CONNECTION_PREFACE}"
19
21
  end
20
22
 
21
- it "reads back opaque bytes from HTTY chunks" do
22
- stream.write(Protocol::HTTP2::CONNECTION_PREFACE)
23
+ it "consumes the bootstrap before reading raw bytes" do
24
+ writer.write("\eP+Hraw\e\\#{Protocol::HTTP2::CONNECTION_PREFACE}")
23
25
  writer.rewind
24
-
25
- reader = subject.new(writer, StringIO.new)
26
+
27
+ reader = subject.open(IO::Stream::Duplex(writer, StringIO.new), bootstrap: :read)
26
28
 
27
29
  expect(reader.read(Protocol::HTTP2::CONNECTION_PREFACE.bytesize)).to be == Protocol::HTTP2::CONNECTION_PREFACE
28
- reader.close
29
- expect(reader.read).to be_nil
30
+ expect(reader.read).to be == ""
30
31
  end
31
32
 
32
- it "returns all buffered bytes when length is omitted" do
33
- stream.write("hello")
33
+ it "writes the bootstrap when opened in write mode" do
34
+ writer = StringIO.new
35
+ stream = subject.open(IO::Stream::Duplex(StringIO.new, writer), bootstrap: :write)
36
+
37
+ expect(writer.string).to be == "\eP+Hraw\e\\"
34
38
  stream.close
39
+ end
40
+
41
+ it "returns all bytes when length is omitted" do
42
+ writer.write("hello")
35
43
  writer.rewind
36
44
 
37
- reader = subject.new(writer, StringIO.new)
45
+ reader = subject.open(IO::Stream::Duplex(writer, StringIO.new))
38
46
 
39
47
  expect(reader.read).to be == "hello"
40
- expect(reader.read).to be_nil
48
+ expect(reader.read).to be == ""
41
49
  end
42
50
 
43
51
  it "exposes the underlying output stream" do
44
52
  expect(stream.io).to be(:is_a?, ::IO::Stream::Buffered)
45
53
  end
46
54
 
47
- it "flushes through the underlying framer" do
55
+ it "flushes through the underlying stream" do
48
56
  stream.write("hello")
49
57
 
50
58
  expect do
@@ -65,6 +73,14 @@ describe Protocol::HTTY::Stream do
65
73
 
66
74
  expect(writer).not.to be(:closed?)
67
75
  end
76
+
77
+ it "close_write closes writes but preserves readability" do
78
+ stream.close_write
79
+
80
+ expect(stream).to be(:closed?)
81
+ expect(stream).to be(:readable?)
82
+ expect(writer).not.to be(:closed?)
83
+ end
68
84
 
69
85
  it "reports when the remote side is still readable" do
70
86
  expect(stream).to be(:readable?)
@@ -80,7 +96,7 @@ describe Protocol::HTTY::Stream do
80
96
 
81
97
  it "wraps raw IO handles using IO::Stream" do
82
98
  Tempfile.create("protocol-htty") do |file|
83
- io_stream = subject.new(file, file).io
99
+ io_stream = subject.open(file).io
84
100
 
85
101
  expect(io_stream).to be(:is_a?, ::IO::Stream::Buffered)
86
102
  io_stream.close
@@ -89,7 +105,7 @@ describe Protocol::HTTY::Stream do
89
105
 
90
106
  it "does not close wrapped raw IO handles when closed" do
91
107
  Tempfile.create("protocol-htty") do |file|
92
- wrapped_stream = subject.new(file, file)
108
+ wrapped_stream = subject.open(file)
93
109
 
94
110
  wrapped_stream.close
95
111
 
data.tar.gz.sig CHANGED
@@ -1,3 +1,2 @@
1
- 9�1H��t���e&�a�A\�����~� d{i����N��`�޿� ��i�0�T���ې%��
2
- `��/���З�!��h��2_���VNPUϳ�qr�.DMn���&���a�@�����Қ4�ovKSn����0�Ͼl�-�;W�s�è�mI �����w�����.��0��ȷ��8(^t[)� k����|�J��(ƻ� g'ۉIk�&�Hy��T�mk�:"[�
3
- _��u����<���T:�C�ga��jW����rP-My��f����O
1
+
2
+ U�:� �k�آ�$���拡 ��x.��ٞ���TF�e#v�ev>���!3�~��TCF2m,�{���0�́jp}��ns�����0lOܢt��姸��84\�:P�4�[m���p/2~?�":���b�ҷD7q��[m�\����N���-#��� ������T[|�鈟\��ɱQ�G��$��Dtfv|�1UO
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-htty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -38,20 +38,6 @@ cert_chain:
38
38
  -----END CERTIFICATE-----
39
39
  date: 1980-01-02 00:00:00.000000000 Z
40
40
  dependencies:
41
- - !ruby/object:Gem::Dependency
42
- name: base64
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - ">="
46
- - !ruby/object:Gem::Version
47
- version: '0'
48
- type: :runtime
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - ">="
53
- - !ruby/object:Gem::Version
54
- version: '0'
55
41
  - !ruby/object:Gem::Dependency
56
42
  name: protocol-http2
57
43
  requirement: !ruby/object:Gem::Requirement
@@ -72,28 +58,28 @@ dependencies:
72
58
  requirements:
73
59
  - - ">="
74
60
  - !ruby/object:Gem::Version
75
- version: '0'
61
+ version: 0.13.0
76
62
  type: :runtime
77
63
  prerelease: false
78
64
  version_requirements: !ruby/object:Gem::Requirement
79
65
  requirements:
80
66
  - - ">="
81
67
  - !ruby/object:Gem::Version
82
- version: '0'
68
+ version: 0.13.0
83
69
  executables: []
84
70
  extensions: []
85
71
  extra_rdoc_files: []
86
72
  files:
87
73
  - lib/protocol/htty.rb
88
74
  - lib/protocol/htty/error.rb
89
- - lib/protocol/htty/framer.rb
90
75
  - lib/protocol/htty/stream.rb
91
76
  - lib/protocol/htty/version.rb
92
77
  - license.md
93
78
  - readme.md
94
79
  - releases.md
95
80
  - test/protocol/htty.rb
96
- - test/protocol/htty/framer.rb
81
+ - test/protocol/htty/bootstrap.rb
82
+ - test/protocol/htty/pty.rb
97
83
  - test/protocol/htty/stream.rb
98
84
  homepage: https://github.com/socketry/protocol-htty
99
85
  licenses:
metadata.gz.sig CHANGED
Binary file
@@ -1,112 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2026, by Samuel Williams.
5
-
6
- require "base64"
7
- require "protocol/http2/framer"
8
-
9
- module Protocol
10
- module HTTY
11
- # Encode and decode HTTY chunks on top of byte-oriented IO objects.
12
- class Framer
13
- ESC = "\e"
14
- DCS = "#{ESC}P"
15
- ST = "#{ESC}\\"
16
- PREFIX = "HTTY;1;"
17
-
18
- # Create a framer around the given input and output streams.
19
- # @parameter input [Interface(:read)] The stream to read framed packets from.
20
- # @parameter output [Interface(:write, :flush) | Nil] The stream to write framed packets to.
21
- def initialize(input, output = input)
22
- @input = input
23
- @output = output
24
- end
25
-
26
- attr :input
27
- attr :output
28
-
29
- # Write a single HTTY chunk to the output stream.
30
- # @parameter payload [String | Array(Integer)] The opaque bytes to encode.
31
- # @returns [void]
32
- def write_chunk(payload)
33
- encoded = Base64.strict_encode64(payload.to_s.b)
34
- @output.write("#{DCS}#{PREFIX}#{encoded}#{ST}")
35
- end
36
-
37
- # Read the next HTTY chunk from the input stream.
38
- # Non-HTTY terminal output is ignored until a valid chunk prefix is found.
39
- # @returns [String | Nil] The decoded payload, or `nil` on end of stream.
40
- # @raises [ProtocolError] If the chunk prefix or chunk structure is invalid.
41
- # @raises [ArgumentError] If the packet payload is not valid base64.
42
- # @raises [EOFError] If the chunk terminator is missing.
43
- def read_chunk
44
- while payload = read_payload
45
- if payload.start_with?("HTTY;") && !payload.start_with?(PREFIX)
46
- raise ProtocolError, "Unsupported HTTY chunk version: #{payload.inspect}"
47
- end
48
-
49
- next unless payload.start_with?(PREFIX)
50
- encoded = payload.delete_prefix(PREFIX)
51
- return Base64.strict_decode64(encoded)
52
- end
53
-
54
- return nil
55
- end
56
-
57
- # Flush the output stream if it supports flushing.
58
- # @returns [void]
59
- def flush
60
- @output.flush if @output.respond_to?(:flush)
61
- end
62
-
63
- # Close the wrapped input and output streams.
64
- # If input and output are the same object, it is only closed once.
65
- # @returns [void]
66
- def close
67
- @output.close if @output.respond_to?(:close)
68
- @input.close if !@input.equal?(@output) && @input.respond_to?(:close)
69
- end
70
-
71
- # Check whether the output stream has been closed.
72
- # @returns [bool] True if the output stream reports that it is closed.
73
- def closed?
74
- @output.respond_to?(:closed?) && @output.closed?
75
- end
76
-
77
- private
78
-
79
- def read_payload
80
- while prefix = @input.read(1)
81
- next unless prefix == ESC
82
-
83
- marker = @input.read(1)
84
- return nil unless marker
85
- next unless marker == "P"
86
-
87
- return consume_packet
88
- end
89
-
90
- return nil
91
- end
92
-
93
- def consume_packet
94
- buffer = +""
95
-
96
- while chunk = @input.read(1)
97
- if chunk == ESC
98
- terminator = @input.read(1)
99
- return buffer if terminator == "\\"
100
-
101
- buffer << chunk
102
- buffer << terminator if terminator
103
- else
104
- buffer << chunk
105
- end
106
- end
107
-
108
- raise EOFError, "Incomplete HTTY chunk!"
109
- end
110
- end
111
- end
112
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2026, by Samuel Williams.
5
-
6
- require "stringio"
7
- require "protocol/http2/framer"
8
- require "protocol/htty"
9
-
10
- describe Protocol::HTTY::Framer do
11
- let(:input) {StringIO.new}
12
- let(:output) {StringIO.new}
13
- let(:framer) {subject.new(input, output)}
14
-
15
- it "writes terminal-safe chunks" do
16
- framer.write_chunk(Protocol::HTTP2::CONNECTION_PREFACE)
17
-
18
- expect(output.string).to be == "\ePHTTY;1;UFJJICogSFRUUC8yLjANCg0KU00NCg0K\e\\"
19
- end
20
-
21
- it "reads terminal-safe chunks" do
22
- input.string = "hello\ePHTTY;1;UFJJICogSFRUUC8yLjANCg0KU00NCg0K\e\\world"
23
- input.rewind
24
-
25
- chunk = framer.read_chunk
26
-
27
- expect(chunk).to be == Protocol::HTTP2::CONNECTION_PREFACE
28
- end
29
-
30
- it "raises on malformed chunks" do
31
- input.string = "\ePHTTY;1\e\\"
32
- input.rewind
33
-
34
- expect do
35
- framer.read_chunk
36
- end.to raise_exception(Protocol::HTTY::ProtocolError)
37
- end
38
-
39
- it "raises on incomplete chunks" do
40
- input.string = "\ePHTTY;1;QUJD"
41
- input.rewind
42
-
43
- expect do
44
- framer.read_chunk
45
- end.to raise_exception(EOFError)
46
- end
47
-
48
- it "closes distinct input and output streams" do
49
- framer.close
50
-
51
- expect(input).to be(:closed?)
52
- expect(output).to be(:closed?)
53
- expect(framer).to be(:closed?)
54
- end
55
-
56
- it "handles escaped bytes inside an invalid chunk payload" do
57
- input.string = "\ePHTTY;1;QUJD\eXRA==\e\\"
58
- input.rewind
59
-
60
- expect do
61
- framer.read_chunk
62
- end.to raise_exception(ArgumentError)
63
- end
64
- end