class Ladybug::Middleware

Public Class Methods

new(app) click to toggle source
# File lib/ladybug/middleware.rb, line 14
def initialize(app)
  @app = app
  @scripts = {}

  @script_repository = ScriptRepository.new
  @debugger =
    Debugger.new(preload_paths: @script_repository.all.map(&:path))
  @object_manager = ObjectManager.new
end

Public Instance Methods

call(env) click to toggle source
# File lib/ladybug/middleware.rb, line 24
def call(env)
  puts "Debug in Chrome: chrome-devtools://devtools/bundled/inspector.html?ws=#{env['HTTP_HOST']}"

  # For now, all websocket connections are assumed to be a debug connection
  if Faye::WebSocket.websocket?(env)
    ws = create_websocket(env)

    # Return async Rack response
    ws.rack_response
  else
    @debugger.debug do
      @app.call(env)
    end
  end
end

Private Instance Methods

create_websocket(env) click to toggle source
# File lib/ladybug/middleware.rb, line 49
def create_websocket(env)
  ws = Faye::WebSocket.new(env)

  ws.on :message do |event|
    # The WebSockets library silently swallows errors.
    # Insert our own error handling for debugging purposes.

    begin
      data = JSON.parse(event.data)

      if data["method"] == "Page.getResourceTree"
        result = {
          frameTree: {
            frame: {
              id: "123",
              loaderId: "123",
              mimeType: "text/plain",
              securityOrigin: "http://localhost",
              url: "http://localhost"
            },
            resources: @script_repository.all.map do |script|
              {
                mimeType: "text/plain",
                type: "Script",
                contentSize: script.size,
                lastModified: script.last_modified_time.to_i,
                url: script.virtual_url
              }
            end
          }
        }
      elsif data["method"] == "Page.getResourceContent"
        result = {
          base64Encoded: false,
          content: "hello world"
        }
      elsif data["method"] == "Debugger.getScriptSource"
        script_id = data["params"]["scriptId"]
        path = @script_repository.find(id: script_id).path
        result = {
          scriptSource: File.new(path, "r").read
        }
      elsif data["method"] == "Debugger.getPossibleBreakpoints"
        script = @script_repository.find(id: data["params"]["start"]["scriptId"])

        # we convert to/from 0-indexed line numbers in Chrome
        # at the earliest/latest possible moment;
        # in this gem, lines are 1-indexed
        start_num = data["params"]["start"]["lineNumber"] + 1
        end_num = data["params"]["end"]["lineNumber"] + 1

        breakpoint_lines = @debugger.get_possible_breakpoints(
          path: script.path, start_num: start_num, end_num: end_num
        )

        locations = breakpoint_lines.map do |breakpoint_line|
          {
            scriptId: script.id,
            lineNumber: breakpoint_line - 1,
            columnNumber: 0
          }
        end

        result = { locations: locations }
      elsif data["method"] == "Debugger.setBreakpointByUrl"
        # Chrome gives us a virtual URL;
        # we need an absolute path to the file to match the API for set_trace_func
        script = @script_repository.find(virtual_url: data["params"]["url"])

        # DevTools gives us 0-indexed line numbers but
        # ruby uses 1-indexed line numbers
        line_number = data["params"]["lineNumber"]
        ruby_line_number = line_number + 1

        begin
          breakpoint = @debugger.set_breakpoint(
            filename: script.path,
            line_number: ruby_line_number
          )

          result = {
            breakpointId: breakpoint[:id],
            locations: [
              {
                scriptId: script.id,
                # todo: need to get these +/- transformations centralized.
                # a LineNumber class might be necessary...
                lineNumber: breakpoint[:line_number] - 1,
                columnNumber: data["params"]["columnNumber"],
              }
            ]
          }
        rescue Debugger::InvalidBreakpointLocationError
          result = {}
        end
      elsif data["method"] == "Debugger.resume"
        # Synchronously just ack the command;
        # we'll async hear back from the main thread when execution resumes
        @debugger.resume
        result = {}
      elsif data["method"] == "Debugger.stepOver"
        # Synchronously just ack the command;
        # we'll async hear back from the main thread when execution resumes
        @debugger.step_over
        result = {}
      elsif data["method"] == "Debugger.stepInto"
        # Synchronously just ack the command;
        # we'll async hear back from the main thread when execution resumes
        @debugger.step_into
        result = {}
      elsif data["method"] == "Debugger.stepOut"
        # Synchronously just ack the command;
        # we'll async hear back from the main thread when execution resumes
        @debugger.step_out
        result = {}
      elsif data["method"] == "Debugger.evaluateOnCallFrame"
        expression = data["params"]["expression"]

        begin
          evaluated = @debugger.evaluate(expression)
        rescue Debugger::InvalidExpressionError
          # A better thing to do would be to throw the syntax error
          # back to the user, but for now we just return nil if
          # given invalid input
          evaluated = nil
        end

        result = {
          result: @object_manager.serialize(evaluated)
        }
      elsif data["method"] == "Debugger.removeBreakpoint"
        @debugger.remove_breakpoint(data["params"]["breakpointId"])
        result = {}
      elsif data["method"] == "Runtime.getProperties"
        object = @object_manager.find(data["params"]["objectId"])

        result = {
          result: @object_manager.get_properties(object)
        }
      else
        result = {}
      end

      response = {
        id: data["id"],
        result: result
      }

      ws.send(response.to_json)

      # After we send a resource tree response, we need to send these
      # messages as well to get the files to show up
      if data["method"] == "Page.getResourceTree"
        @script_repository.all.each do |script|
          message = {
            method: "Debugger.scriptParsed",
            params: {
              scriptId: script.id,
              url: script.virtual_url,
              startLine: 0,
              startColumn: 0,
              endLine: 100, #todo: really populate
              endColumn: 100
            }
          }.to_json

          ws.send(message)
        end

        # Create a runtime context so Chrome can
        # accept user input in the console

        message = {
          method: "Runtime.executionContextCreated",
          params: {
            context: {
              id: 12, # random number for now
              name: "",
              origin: "http://localhost:3000" ,
              auxData: {
                isDefault: true,
                frameId: SecureRandom.uuid
              }
            }
          }
        }.to_json

        ws.send(message)
      end
    rescue => e
      puts e.message
      puts e.backtrace

      raise e
    end
  end

  ws.on :close do |event|
    p [:close, event.code, event.reason]
    ws = nil
  end

  # Spawn a thread to handle messages from the main thread
  # and notify the client.

  @debugger.on_pause do |info|
    # Generate an object representing this scope
    # (this is here not in the debugger because the debugger
    #  shouldn't need to know about the requirement for a virtual object)
    virtual_scope_object =
      info[:local_variables].merge(info[:instance_variables])

    # Register the virtual object to give it an ID and hold a ref to it
    object_id = @object_manager.register(virtual_scope_object)

    script = @script_repository.find(absolute_path: info[:filename])

    # currently we don't support going into functions
    # that aren't in the path of our current app.
    if script.nil?
      puts "Debugger was paused on file outside of app: #{info[:filename]}"
      puts "ladybug currently only supports pausing in app files."
      @debugger.resume
    else
      location = {
        scriptId: script.id,
        lineNumber: info[:line_number] - 1,
        columnNumber: 0
      }

      msg_to_client = {
        method: "Debugger.paused",
        params: {
          callFrames: [
            {
              location: location,
              callFrameId: SecureRandom.uuid,
              functionName: info[:label],
              scopeChain: [
                {
                  type: "local",
                  startLocation: location,
                  endLocation: location,
                  object: {
                    className: "Object",
                    description: "Object",
                    type: "object",
                    objectId: object_id
                  }
                }
              ],
              url: script.virtual_url
            }
          ],
          hitBreakpoints: info[:breakpoint_id] ? [info[:breakpoint_id]] : [],
          reason: "other"
        }
      }

      ws.send(msg_to_client.to_json)
    end
  end

  @debugger.on_resume do
    msg_to_client = {
      method: "Debugger.resumed",
      params: {}
    }

    ws.send(msg_to_client.to_json)
  end

  ws
end
sanitize_expression(expression) click to toggle source

Avoid eval'ing weird expressions that Chrome sends us, like Javascript functions and other things. This is an initial hack implementation, could be better.

# File lib/ladybug/middleware.rb, line 45
def sanitize_expression(expression)
  expression
end