bidi2pdf 0.1.1 → 0.1.2

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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/CHANGELOG.md +29 -3
  4. data/README.md +10 -0
  5. data/Rakefile +2 -0
  6. data/cliff.toml +114 -0
  7. data/docker/Dockerfile.chromedriver +33 -0
  8. data/docker/docker-compose.yml +7 -0
  9. data/docker/entrypoint.sh +3 -0
  10. data/lib/bidi2pdf/bidi/add_headers_interceptor.rb +1 -1
  11. data/lib/bidi2pdf/bidi/auth_interceptor.rb +1 -1
  12. data/lib/bidi2pdf/bidi/client.rb +55 -140
  13. data/lib/bidi2pdf/bidi/command_manager.rb +82 -0
  14. data/lib/bidi2pdf/bidi/connection_manager.rb +34 -0
  15. data/lib/bidi2pdf/bidi/session.rb +26 -9
  16. data/lib/bidi2pdf/chromedriver_manager.rb +19 -4
  17. data/lib/bidi2pdf/cli.rb +147 -18
  18. data/lib/bidi2pdf/launcher.rb +19 -6
  19. data/lib/bidi2pdf/version.rb +1 -1
  20. data/sig/bidi2pdf/bidi/add_headers_interceptor.rbs +20 -0
  21. data/sig/bidi2pdf/bidi/auth_interceptor.rbs +17 -0
  22. data/sig/bidi2pdf/bidi/browser.rbs +38 -0
  23. data/sig/bidi2pdf/bidi/browser_tab.rbs +42 -0
  24. data/sig/bidi2pdf/bidi/client.rbs +72 -0
  25. data/sig/bidi2pdf/bidi/event_manager.rbs +29 -0
  26. data/sig/bidi2pdf/bidi/network_event.rbs +51 -0
  27. data/sig/bidi2pdf/bidi/network_events.rbs +55 -0
  28. data/sig/bidi2pdf/bidi/print_parameters_validator.rbs +44 -0
  29. data/sig/bidi2pdf/bidi/session.rbs +52 -0
  30. data/sig/bidi2pdf/bidi/user_context.rbs +50 -0
  31. data/sig/bidi2pdf/bidi/web_socket_dispatcher.rbs +53 -0
  32. data/sig/bidi2pdf/chromedriver_manager.rbs +42 -0
  33. data/sig/bidi2pdf/cli.rbs +21 -0
  34. data/sig/bidi2pdf/launcher.rbs +38 -0
  35. data/sig/bidi2pdf/process_tree.rbs +27 -0
  36. data/sig/bidi2pdf/session_runner.rbs +51 -0
  37. data/sig/bidi2pdf/utils.rbs +5 -0
  38. data/sig/vendor/thor.rbs +13 -0
  39. data/tasks/changelog.rake +29 -0
  40. data/tasks/generate_rbs.rake +64 -0
  41. metadata +48 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a67b40e096dd2b6b73fa65cec7d9d75182b995435c17186d755f89efebb2ab26
4
- data.tar.gz: fde0861d58dd30788aa1b54a24d956d3438bd83cc08a5ec9d22e6750a3e646d6
3
+ metadata.gz: dbc473cb8b54517914bfa958b1925d97db54ab3e6ee640a28101b48346077192
4
+ data.tar.gz: 76c2d11f399a33a932f66348cfc3aad9d0c80f1e15407566b10d16a646b17d1c
5
5
  SHA512:
6
- metadata.gz: 52e199c72b902e046140e6d47a9b00a8354e53a88f3af6135f0d7ab587c7d59532a904388e4a62ed4ba29c03609eab3b8c7f3aea0aa131ba8880f8ecb6f9a503
7
- data.tar.gz: a83f7091a455e5c8cb5f2b7e8e621c044b2184c2c6e5a6e11aaa4f4ca2f9879ada89eb7951e7ad78e07a2d473396bb529ce5c4dfc771130a40a1977bb6e60575
6
+ metadata.gz: eb72b6bc4eeb2a65172c37138be3f2af5129a046a217acaf90236532c323547e0f1456f7743c509015808389da586b145cd069a26a85bbc35278c44cfea32249
7
+ data.tar.gz: e3b6d68003a6419e86fa33a68b51c065d7d60f95cc7bf657b12cb13ed5bef085938599b5387aca641b1a699ec47fab6804e300892b962a842d06748b061a1e5d
data/.rubocop.yml CHANGED
@@ -45,6 +45,9 @@ Gemspec/DevelopmentDependencies:
45
45
  RSpec/InstanceVariable:
46
46
  Enabled: false
47
47
 
48
+ RSpec/BeforeAfterAll:
49
+ Enabled: false
50
+
48
51
  plugins:
49
52
  - rubocop-rake
50
53
  - rubocop-rspec
data/CHANGELOG.md CHANGED
@@ -1,8 +1,34 @@
1
- ## [Unreleased]
1
+ <!-- generated by git-cliff start -->
2
+
3
+ # Changelog
4
+
5
+ All notable changes to this project will be documented in this file.
6
+
7
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
8
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
9
+ <!-- generated by git-cliff end -->
10
+
11
+ ## [0.1.2] - 2025-04-05
12
+
13
+ ### 🔄 Changed
14
+
15
+ - Enhance CLI with YAML configuration support and additional options by @dieter-medium
16
+
17
+ ### 🚀 Added
18
+
19
+ - Add CLI tests for rendering and configuration options by @dieter-medium
20
+ - Add RSpec test for Bidi2pdf version number by @dieter-medium
21
+ - Add PDF rendering test with custom print parameters and debug output by @dieter-medium
22
+ - Add be_alive_process matcher and chromedriver manager specs for process management by @dieter-medium
23
+ - Add RBS type signatures for Bidi2pdf classes and modules by @dieter-medium
24
+ - Add build status badge to README by @dieter-medium
25
+ - Add badges for maintainability, gem version, and test coverage in README by @dieter-medium
26
+ - Add Code Climate coverage reporting to CI workflow by @dieter-medium
27
+ - Add support for remote ChromeDriver connections and update Docker setup by @dieter-medium
2
28
 
3
29
  ## [0.1.1] - 2025-04-01
4
30
 
5
- ### Added
31
+ ### 🚀 Added
6
32
 
7
33
  - Docker Compose setup with Nginx for authentication examples
8
34
  - Sample configurations for different authentication methods:
@@ -10,7 +36,7 @@
10
36
  - Cookie-based authentication
11
37
  - API key header authentication
12
38
 
13
- ### Fixed
39
+ ### 🐛 Fixed
14
40
 
15
41
  - HTTP cookie handling issues
16
42
 
data/README.md CHANGED
@@ -1,3 +1,8 @@
1
+ [![Build Status](https://github.com/dieter-medium/bidi2pdf/actions/workflows/ruby.yml/badge.svg)](https://github.com/dieter-medium/bidi2pdf/blob/main/.github/workflows/ruby.yml)
2
+ [![Maintainability](https://api.codeclimate.com/v1/badges/6425d9893aa3a9ca243e/maintainability)](https://codeclimate.com/github/dieter-medium/bidi2pdf/maintainability)
3
+ [![Gem Version](https://badge.fury.io/rb/bidi2pdf.svg)](https://badge.fury.io/rb/bidi2pdf)
4
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/6425d9893aa3a9ca243e/test_coverage)](https://codeclimate.com/github/dieter-medium/bidi2pdf/test_coverage)
5
+
1
6
  # Bidi2pdf
2
7
 
3
8
  Bidi2pdf is a Ruby gem that generates high-quality PDFs from web pages using Chrome's BiDi (BiDirectional) protocol. It
@@ -94,6 +99,7 @@ docker run -it --rm -v ./output:/reports bidi2pdf \
94
99
  ### Test it with docker compose
95
100
 
96
101
  ```bash
102
+ rake build
97
103
  docker compose -f docker/docker-compose.yml up -d
98
104
 
99
105
  # simple example
@@ -108,6 +114,8 @@ docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http:
108
114
  # cookie example
109
115
  docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/cookie/sample.html --cookie "auth=secret" --wait_window_loaded --wait_network_idle --output /reports/cookie.pdf
110
116
 
117
+ # remote chrome example
118
+ docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/cookie/sample.html --remote_browser_url http://remote-chrome:3000/session --cookie "auth=secret" --wait_window_loaded --wait_network_idle --output /reports/remote.pdf
111
119
 
112
120
  docker compose -f docker/docker-compose.yml down
113
121
  ```
@@ -126,6 +134,8 @@ docker compose -f docker/docker-compose.yml down
126
134
  | `--wait_window_loaded` | Wait for the window to be fully loaded. You need to set a variable `window.loaded`. See ./spec/fixtures/sample.html |
127
135
  | `--wait_network_idle` | Wait for network to be idle |
128
136
  | `--log_level` | Log level (debug, info, warn, error, fatal) |
137
+ | `--remote_browser_url` | URL of the remote Chrome instance (default: nil) |
138
+ | `--default_timeout` | Default timeout for operations (default: 60 seconds) |
129
139
 
130
140
  ## Development
131
141
 
data/Rakefile CHANGED
@@ -14,6 +14,8 @@ task default: %i[spec rubocop]
14
14
  require "chromedriver/binary"
15
15
  load "chromedriver/Rakefile"
16
16
 
17
+ Dir.glob("tasks/*.rake").each { |r| load r }
18
+
17
19
  desc "Run tests with coverage"
18
20
  task :coverage do
19
21
  ENV["COVERAGE"] = "true"
data/cliff.toml ADDED
@@ -0,0 +1,114 @@
1
+ # git-cliff ~ configuration file
2
+ # https://git-cliff.org/docs/configuration
3
+
4
+ [changelog]
5
+ # template for the changelog header
6
+ header = """
7
+ <!-- generated by git-cliff start -->
8
+ # Changelog\n
9
+ All notable changes to this project will be documented in this file.
10
+
11
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
12
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n
13
+ """
14
+ # template for the changelog body
15
+ # https://keats.github.io/tera/docs/#introduction
16
+ body = """
17
+ {%- macro remote_url() -%}
18
+ https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
19
+ {%- endmacro -%}
20
+
21
+ {% if version -%}
22
+ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
23
+ {% else -%}
24
+ ## [Unreleased]
25
+ {% endif -%}
26
+
27
+ {% for group, commits in commits | group_by(attribute="group") %}
28
+ ### {{ group | upper_first }}
29
+ {%- for commit in commits %}
30
+ - {{ commit.message | split(pat="\n") | first | upper_first | trim }}\
31
+ {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%}
32
+ {% if commit.remote.pr_number %} in \
33
+ [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \
34
+ {%- endif -%}
35
+ {% endfor %}
36
+ {% endfor %}
37
+
38
+ {%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
39
+ ## New Contributors
40
+ {%- endif -%}
41
+
42
+ {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
43
+ * @{{ contributor.username }} made their first contribution
44
+ {%- if contributor.pr_number %} in \
45
+ [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
46
+ {%- endif %}
47
+ {%- endfor %}\n
48
+ """
49
+ # template for the changelog footer
50
+ footer = """
51
+ {%- macro remote_url() -%}
52
+ https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
53
+ {%- endmacro -%}
54
+
55
+ {% for release in releases -%}
56
+ {% if release.version -%}
57
+ {% if release.previous.version -%}
58
+ [{{ release.version | trim_start_matches(pat="v") }}]: \
59
+ {{ self::remote_url() }}/compare/{{ release.previous.version }}..{{ release.version }}
60
+ {% endif -%}
61
+ {% else -%}
62
+ [unreleased]: {{ self::remote_url() }}/compare/{{ release.previous.version }}..HEAD
63
+ {% endif -%}
64
+ {% endfor %}
65
+ <!-- generated by git-cliff end -->
66
+ """
67
+ # remove the leading and trailing whitespace from the templates
68
+ trim = true
69
+
70
+ [git]
71
+ # parse the commits based on https://www.conventionalcommits.org
72
+ conventional_commits = true
73
+ # filter out the commits that are not conventional
74
+ filter_unconventional = false
75
+ # regex for preprocessing the commit messages
76
+ commit_preprocessors = [
77
+ # remove issue numbers from commits
78
+ { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" },
79
+ ]
80
+ # regex for parsing and grouping commits
81
+ commit_parsers = [
82
+ # ✅ Additions
83
+ { message = "^[a|A]dd", group = "🚀 Added" },
84
+ { message = "^[s|S]upport", group = "🚀 Added" },
85
+ { message = "^.*: add", group = "🚀 Added" },
86
+ { message = "^.*: support", group = "🚀 Added" },
87
+
88
+ # ❌ Removals
89
+ { message = "^[r|R]emove", group = "🗑️ Removed" },
90
+ { message = "^.*: remove", group = "🗑️ Removed" },
91
+ { message = "^.*: delete", group = "🗑️ Removed" },
92
+
93
+ # 🐛 Fixes
94
+ { message = "^test", group = "🐛 Fixed" },
95
+ { message = "^fix", group = "🐛 Fixed" },
96
+ { message = "^.*: fix", group = "🐛 Fixed" },
97
+
98
+ # ⛔️ Chore & CI commits to skip
99
+ { message = "^chore\\(release\\): prepare for", skip = true },
100
+ { message = "^chore\\(deps.*\\)", skip = true },
101
+ { message = "^chore\\(pr\\)", skip = true },
102
+ { message = "^chore\\(pull\\)", skip = true },
103
+ { message = "^chore", skip = true },
104
+ { message = "^ci", skip = true },
105
+
106
+ # 🌀 Catch-All
107
+ { message = "^.*", group = "🔄 Changed" }
108
+ ]
109
+ # filter out the commits that are not matched by commit parsers
110
+ filter_commits = false
111
+ # sort the tags topologically
112
+ topo_order = false
113
+ # sort the commits inside sections by oldest/newest order
114
+ sort_commits = "newest"
@@ -0,0 +1,33 @@
1
+ FROM ruby:3.3
2
+
3
+ # Install dependencies
4
+ RUN apt-get update && \
5
+ apt-get install -y \
6
+ chromium \
7
+ libglib2.0-0 \
8
+ libnss3 \
9
+ libxss1 \
10
+ libasound2 \
11
+ libatk-bridge2.0-0 \
12
+ libgtk-3-0 \
13
+ libdrm2 \
14
+ curl \
15
+ unzip \
16
+ xvfb \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ # Create a non-root user
20
+ RUN groupadd -r appuser && useradd -r -g appuser -m -d /home/appuser appuser
21
+
22
+ COPY ./docker/entrypoint.sh /usr/local/bin/entrypoint.sh
23
+ RUN chmod +x /usr/local/bin/entrypoint.sh
24
+
25
+ # Set working directory
26
+ WORKDIR /app
27
+
28
+ # Switch to non-root user
29
+ USER appuser
30
+
31
+ RUN gem install chromedriver-binary && ruby -e 'require "chromedriver/binary"; puts Chromedriver::Binary::ChromedriverDownloader.update'
32
+
33
+ CMD ["/usr/local/bin/entrypoint.sh"]
@@ -9,6 +9,13 @@ services:
9
9
  - ./nginx/htpasswd:/etc/nginx/conf.d/.htpasswd
10
10
  - ../spec/fixtures:/var/www/html
11
11
 
12
+ remote-chrome:
13
+ build:
14
+ context: ..
15
+ dockerfile: docker/Dockerfile.chromedriver
16
+ ports:
17
+ - "9092:3000"
18
+
12
19
  app:
13
20
  build:
14
21
  context: ..
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+
3
+ /home/appuser/.webdrivers/chromedriver --port=3000 --headless --whitelisted-ips="" --allowed-origins="*" --disable-dev-shm-usage --verbose
@@ -5,7 +5,7 @@ module Bidi2pdf
5
5
  class AddHeadersInterceptor
6
6
  attr_reader :id, :headers
7
7
 
8
- def initialize(id, headers, client)
8
+ def initialize(id, headers:, client:)
9
9
  @id = id
10
10
  @client = client
11
11
  @headers = headers.map do |header|
@@ -5,7 +5,7 @@ module Bidi2pdf
5
5
  class AuthInterceptor
6
6
  attr_reader :id, :username, :password, :network_ids
7
7
 
8
- def initialize(id, username, password, client)
8
+ def initialize(id, username:, password:, client:)
9
9
  @id = id
10
10
  @client = client
11
11
  @username = username
@@ -6,6 +6,8 @@ require "websocket-client-simple"
6
6
  require_relative "web_socket_dispatcher"
7
7
  require_relative "add_headers_interceptor"
8
8
  require_relative "auth_interceptor"
9
+ require_relative "command_manager"
10
+ require_relative "connection_manager"
9
11
 
10
12
  module Bidi2pdf
11
13
  module Bidi
@@ -21,7 +23,7 @@ module Bidi2pdf
21
23
 
22
24
  @connected = false
23
25
  @connection_mutex = Mutex.new
24
- @send_cmd_mutex = Mutex.new
26
+ @next_id_mutex = Mutex.new
25
27
  @connection_cv = ConditionVariable.new
26
28
 
27
29
  @started = false
@@ -31,192 +33,105 @@ module Bidi2pdf
31
33
  return @socket if started?
32
34
 
33
35
  @socket = WebSocket::Client::Simple.connect(ws_url)
34
- @dispatcher = WebSocketDispatcher.new(@socket)
35
36
 
36
- @dispatcher.on_open { handle_open }
37
- @dispatcher.on_message { |data| handle_response_to_cmd(data) }
37
+ @connection_manager = ConnectionManager.new(logger: Bidi2pdf.logger)
38
+ @command_manager = CommandManager.new(@socket, logger: Bidi2pdf.logger)
38
39
 
39
- @dispatcher.start_listening
40
+ dispatcher.on_open { @connection_manager.mark_connected }
41
+ dispatcher.on_message { |data| handle_response_to_cmd(data) }
40
42
 
43
+ dispatcher.start_listening
41
44
  @started = true
42
45
 
43
46
  @socket
44
47
  end
45
48
 
46
- def started?
47
- @started
48
- end
49
+ def started? = @started
49
50
 
50
51
  def wait_until_open(timeout: Bidi2pdf.default_timeout)
51
- @connection_mutex.synchronize do
52
- unless @connected
53
- Bidi2pdf.logger.debug "Waiting for WebSocket connection to open"
54
- @connection_cv.wait(@connection_mutex, timeout)
55
- end
56
- end
57
-
58
- raise "WebSocket connection did not open in time" unless @connected
59
-
60
- Bidi2pdf.logger.debug "WebSocket connection is open"
52
+ @connection_manager.wait_until_open(timeout: timeout)
61
53
  end
62
54
 
63
55
  def send_cmd(method, params = {})
64
- next_id.tap do |cmd_id|
65
- payload = {
66
- id: cmd_id,
67
- method: method,
68
- params: params
69
- }
70
-
71
- Bidi2pdf.logger.debug "Sending command: #{redact_sensitive_fields(payload).inspect}"
72
-
73
- @socket.send(payload.to_json)
74
- end
56
+ @command_manager.send_cmd(method, params)
75
57
  end
76
58
 
77
- # rubocop:disable Metrics/AbcSize
78
- def send_cmd_and_wait(method, params = {}, timeout: Bidi2pdf.default_timeout)
59
+ def send_cmd_and_wait(method, params = {}, timeout: Bidi2pdf.default_timeout, &block)
79
60
  timed("Command #{method}") do
80
- id = send_cmd(method, params)
81
- queue = @pending_responses[id]
82
-
83
- begin
84
- response = queue.pop(true)
85
- rescue ThreadError
86
- response = queue.pop(timeout: timeout)
87
- end
88
-
89
- if response.nil?
90
- # rubocop:disable Layout/LineLength
91
- Bidi2pdf.logger.error "Timeout waiting for response to command #{id}, cmd: #{method}, params: #{redact_sensitive_fields(params).inspect}"
92
- # rubocop:enable Layout/LineLength
93
-
94
- raise "Timeout waiting for response to command ID #{id}"
95
- end
96
-
97
- raise "Error response: #{response["error"]}" if response["error"]
98
-
99
- result = response
100
-
101
- result = yield response if block_given?
102
-
103
- result
104
- ensure
105
- @pending_responses.delete(id)
61
+ @command_manager.send_cmd_and_wait(method, params, timeout: timeout, &block)
106
62
  end
107
63
  end
108
64
 
109
- # rubocop:enable Metrics/AbcSize
65
+ def on_message(&block) = dispatcher.on_message(&block)
110
66
 
111
- # Event API for external consumers
112
- def on_message(&block) = @dispatcher.on_message(&block)
67
+ def on_open(&block) = dispatcher.on_open(&block)
113
68
 
114
- def on_open(&block) = @dispatcher.on_open(&block)
69
+ def on_close(&block) = dispatcher.on_close(&block)
115
70
 
116
- def on_close(&block) = @dispatcher.on_close(&block)
117
-
118
- def on_error(&block) = @dispatcher.on_error(&block)
71
+ def on_error(&block) = dispatcher.on_error(&block)
119
72
 
120
73
  def on_event(*names, &block)
121
- names.each do |name|
122
- @dispatcher.on_event(name, &block)
123
- end
124
-
125
- send_cmd "session.subscribe", { events: names } if names.any?
74
+ names.each { |name| dispatcher.on_event(name, &block) }
75
+ send_cmd("session.subscribe", { events: names }) if names.any?
126
76
  end
127
77
 
128
- def remove_message_listener(block) = @dispatcher.remove_message_listener(block)
78
+ def remove_message_listener(block) = dispatcher.remove_message_listener(block)
129
79
 
130
80
  def remove_event_listener(*names, &block)
131
- names.each do |event_name|
132
- @dispatcher.remove_event_listener(event_name, block)
133
- end
81
+ names.each { |event_name| dispatcher.remove_event_listener(event_name, block) }
134
82
  end
135
83
 
136
- def add_headers_interceptor(
137
- context:,
138
- url_patterns:,
139
- headers:
140
- )
141
- send_cmd_and_wait("network.addIntercept", {
84
+ def add_headers_interceptor(context:, url_patterns:, headers:)
85
+ add_interceptor(
142
86
  context: context,
143
- phases: ["beforeRequestSent"],
144
- urlPatterns: url_patterns
145
- }) do |response|
146
- id = response["result"]["intercept"]
147
- Bidi2pdf.logger.debug "Interceptor added: #{id}"
148
-
149
- AddHeadersInterceptor.new(id, headers, self).tap do |interceptor|
150
- on_event "network.beforeRequestSent", &interceptor.method(:handle_event)
151
- end
152
- end
87
+ url_patterns: url_patterns,
88
+ phase: "beforeRequestSent",
89
+ event: "network.beforeRequestSent",
90
+ interceptor_class: AddHeadersInterceptor,
91
+ extra_args: { headers: headers }
92
+ )
153
93
  end
154
94
 
155
- def add_auth_interceptor(
156
- context:,
157
- url_patterns:,
158
- username:,
159
- password:
160
- )
161
- send_cmd_and_wait("network.addIntercept", {
95
+ def add_auth_interceptor(context:, url_patterns:, username:, password:)
96
+ add_interceptor(
162
97
  context: context,
163
- phases: ["authRequired"],
164
- urlPatterns: url_patterns
165
- }) do |response|
166
- id = response["result"]["intercept"]
167
- Bidi2pdf.logger.debug "Interceptor added: #{id}"
168
-
169
- AuthInterceptor.new(id, username, password, self).tap do |interceptor|
170
- on_event "network.authRequired", &interceptor.method(:handle_event)
171
- end
172
- end
98
+ url_patterns: url_patterns,
99
+ phase: "authRequired",
100
+ event: "network.authRequired",
101
+ interceptor_class: AuthInterceptor,
102
+ extra_args: { username: username, password: password }
103
+ )
173
104
  end
174
105
 
175
106
  private
176
107
 
177
- def next_id
178
- cmd_id = nil
179
-
180
- @send_cmd_mutex.synchronize do
181
- @id += 1
182
- cmd_id = @id
183
- @pending_responses[cmd_id] = Queue.new
184
- end
185
-
186
- cmd_id
187
- end
188
-
189
- def handle_open
190
- @connection_mutex.synchronize do
191
- @connected = true
192
- @connection_cv.broadcast
193
- end
108
+ def dispatcher
109
+ @dispatcher ||= WebSocketDispatcher.new(@socket)
194
110
  end
195
111
 
196
112
  def handle_response_to_cmd(data)
197
- if (id = data["id"]) && @pending_responses.key?(id)
198
- @pending_responses[id]&.push(data)
199
- elsif (data = data["error"])
200
- Bidi2pdf.logger.error "Error response: #{data.inspect}"
113
+ handled = @command_manager.handle_response(data)
114
+ return if handled
115
+
116
+ if data["error"]
117
+ Bidi2pdf.logger.error "Error response: #{data["error"].inspect}"
201
118
  else
202
119
  Bidi2pdf.logger.warn "Unknown response: #{data.inspect}"
203
120
  end
204
121
  end
205
122
 
206
- def redact_sensitive_fields(obj, sensitive_keys = %w[value token password authorization username])
207
- case obj
208
- when Hash
209
- obj.each_with_object({}) do |(k, v), result|
210
- result[k] = if sensitive_keys.include?(k.to_s.downcase)
211
- "[REDACTED]"
212
- else
213
- redact_sensitive_fields(v, sensitive_keys)
214
- end
123
+ def add_interceptor(context:, url_patterns:, phase:, event:, interceptor_class:, extra_args: {})
124
+ send_cmd_and_wait("network.addIntercept", {
125
+ context: context,
126
+ phases: [phase],
127
+ urlPatterns: url_patterns
128
+ }) do |response|
129
+ id = response["result"]["intercept"]
130
+ Bidi2pdf.logger.debug "Interceptor added: #{id}"
131
+
132
+ interceptor_class.new(id, **extra_args, client: self).tap do |interceptor|
133
+ on_event(event, &interceptor.method(:handle_event))
215
134
  end
216
- when Array
217
- obj.map { |item| redact_sensitive_fields(item, sensitive_keys) }
218
- else
219
- obj
220
135
  end
221
136
  end
222
137
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ module Bidi
5
+ class CommandManager
6
+ def initialize(socket, logger:)
7
+ @socket = socket
8
+ @logger = logger
9
+
10
+ @id = 0
11
+ @next_id_mutex = Mutex.new
12
+ @pending_responses = {}
13
+ end
14
+
15
+ def send_cmd(method, params = {})
16
+ id = next_id
17
+ payload = { id: id, method: method, params: params }
18
+
19
+ @logger.debug "Sending command: #{redact_sensitive_fields(payload).inspect}"
20
+ @socket.send(payload.to_json)
21
+
22
+ id
23
+ end
24
+
25
+ def send_cmd_and_wait(method, params = {}, timeout: Bidi2pdf.default_timeout)
26
+ id = send_cmd(method, params)
27
+ queue = @pending_responses[id]
28
+
29
+ response = queue.pop(timeout: timeout)
30
+ raise_timeout_error(id, method, params) if response.nil?
31
+ raise "Error response: #{response["error"]}" if response["error"]
32
+
33
+ block_given? ? yield(response) : response
34
+ ensure
35
+ @pending_responses.delete(id)
36
+ end
37
+
38
+ def queue_for(id)
39
+ @pending_responses[id]
40
+ end
41
+
42
+ def handle_response(data)
43
+ if (id = data["id"]) && @pending_responses.key?(id)
44
+ @pending_responses[id]&.push(data)
45
+ else
46
+ false
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def next_id
53
+ @next_id_mutex.synchronize do
54
+ @id += 1
55
+ @pending_responses[@id] = Thread::Queue.new
56
+ @id
57
+ end
58
+ end
59
+
60
+ def redact_sensitive_fields(obj, sensitive_keys = %w[value token password authorization username])
61
+ case obj
62
+ when Hash
63
+ obj.transform_values.with_index do |v, idx|
64
+ k = obj.keys[idx]
65
+ sensitive_keys.include?(k.to_s.downcase) ? "[REDACTED]" : redact_sensitive_fields(v, sensitive_keys)
66
+ end
67
+ when Array
68
+ obj.map { |item| redact_sensitive_fields(item, sensitive_keys) }
69
+ else
70
+ obj
71
+ end
72
+ end
73
+
74
+ def raise_timeout_error(id, method, params)
75
+ # rubocop:disable Layout/LineLength
76
+ @logger.error "Timeout waiting for response to command #{id}, cmd: #{method}, params: #{redact_sensitive_fields(params).inspect}"
77
+ # rubocop:enable Layout/LineLength
78
+ raise "Timeout waiting for response to command ID #{id}"
79
+ end
80
+ end
81
+ end
82
+ end