bidi2pdf 0.1.14 → 0.1.15
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 +4 -4
- data/CHANGELOG.md +49 -2
- data/README.md +68 -5
- data/lib/bidi2pdf/bidi/browser_tab.rb +45 -4
- data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
- data/lib/bidi2pdf/bidi/client.rb +12 -5
- data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
- data/lib/bidi2pdf/bidi/network_event.rb +12 -3
- data/lib/bidi2pdf/bidi/network_events.rb +9 -1
- data/lib/bidi2pdf/bidi/session.rb +1 -1
- data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
- data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
- data/lib/bidi2pdf/session_warmer.rb +377 -0
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +4 -2
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +24 -1
- data/sig/bidi2pdf/bidi/browser_tab.rbs +17 -0
- data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
- data/sig/bidi2pdf/bidi/client.rbs +10 -2
- data/sig/bidi2pdf/bidi/network_event.rbs +11 -1
- data/sig/bidi2pdf/bidi/session.rbs +1 -1
- data/sig/bidi2pdf/chromedriver_manager.rbs +4 -0
- data/sig/bidi2pdf/session_warmer.rbs +221 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +1 -1
- data/sig/bidi2pdf/version.rbs +1 -1
- data/sig/bidi2pdf.rbs +18 -0
- data/tasks/release_credentials_check.rake +97 -0
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36afd0d1f8c64ff6d72a8051785d3f65a7efcb336c07e1a6756b772ff4f17414
|
|
4
|
+
data.tar.gz: 6ecddfbacf09de3757b65009e1c41ca3cb90b8667d2fe80972200159d9f0c2b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0490c1e9e84467c4437eaf65128cf9adddac4509a66e7e41a791f10cb5fee6ee86d5fb821177af022a04a41441c6c57a37e8015e6a288c5e6838ecbc966bb628'
|
|
7
|
+
data.tar.gz: 935185356734aded9f38453cb2e6340501d1f99c482ffc17d62201867dd95224fe7018dfc079cfbdeed72ea44c63233fb5769987ddaa339ed7425ee6b0e281f5
|
data/CHANGELOG.md
CHANGED
|
@@ -8,10 +8,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
-
[unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.
|
|
11
|
+
[unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.15..HEAD
|
|
12
12
|
|
|
13
13
|
<!-- generated by git-cliff end -->
|
|
14
14
|
|
|
15
|
+
## [0.1.15] - 2026-09-20
|
|
16
|
+
|
|
17
|
+
### 🎨 Refactored
|
|
18
|
+
|
|
19
|
+
- Remove deprecated benchmarking script
|
|
20
|
+
|
|
21
|
+
### 🐛 Fixed
|
|
22
|
+
|
|
23
|
+
- Handle partial Chrome session builds and cleanup
|
|
24
|
+
- Ensure consistent use of alias in nginx_url helper
|
|
25
|
+
- Use AtomicFixnum for thread-safe slot counting
|
|
26
|
+
- Refactor Chrome session setup in specs
|
|
27
|
+
- Improve session warmer configuration and timeout handling
|
|
28
|
+
- Decouple chromedriver log level from logger level
|
|
29
|
+
- Remove navigation ID generation for sub-resources
|
|
30
|
+
- Remove redundant timeout error test
|
|
31
|
+
- Improve string truncation for log safety
|
|
32
|
+
- Improve release credentials check for missing keys
|
|
33
|
+
- Enhance WebSocket handling for control frames and errors
|
|
34
|
+
- Add chromedriver log level configuration
|
|
35
|
+
- Adjust benchmark thresholds for CI environments
|
|
36
|
+
- Add string truncation for safe log output
|
|
37
|
+
- Set timeout for RSpec test runs
|
|
38
|
+
- Prevent logging raw data URLs during navigation
|
|
39
|
+
- Generate unique navigation IDs for sub-resource requests
|
|
40
|
+
|
|
41
|
+
### 🔄 Changed
|
|
42
|
+
|
|
43
|
+
- Merge pull request #124 from dieter-medium/feat/session-warmer
|
|
44
|
+
- Merge pull request #123 from dieter-medium/feat/faster-pdf-generation
|
|
45
|
+
- Merge pull request #122 from dieter-medium/chore-prepare-dev
|
|
46
|
+
- Bump version to 0.1.15.pre
|
|
47
|
+
|
|
48
|
+
### 🚀 Added
|
|
49
|
+
|
|
50
|
+
- Implement session warmer idle time management
|
|
51
|
+
- Improve session warmer threading and recovery
|
|
52
|
+
- Enhance session warming and WebSocket health checks
|
|
53
|
+
- Add session warmer and enhance version resolver error handling
|
|
54
|
+
- Add session warming and programmatic configuration
|
|
55
|
+
- Add chromedriver log level mapping and tests
|
|
56
|
+
- Add release credential check for rubygems.org
|
|
57
|
+
- Add navigation id handling in network events
|
|
58
|
+
- Replace WebSocket client library for performance
|
|
59
|
+
- Implement BufferedWebSocketClient and benchmarks
|
|
60
|
+
|
|
15
61
|
## [0.1.14] - 2026-09-19
|
|
16
62
|
|
|
17
63
|
### 🐛 Fixed
|
|
@@ -407,7 +453,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
407
453
|
|
|
408
454
|
### 🔄 Released
|
|
409
455
|
|
|
410
|
-
- [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.
|
|
456
|
+
- [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.15..HEAD)
|
|
457
|
+
- [0.1.15](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.14..v0.1.15)
|
|
411
458
|
- [0.1.14](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.13..v0.1.14)
|
|
412
459
|
- [0.1.13](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.12..v0.1.13)
|
|
413
460
|
- [0.1.12](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.11..v0.1.12)
|
data/README.md
CHANGED
|
@@ -24,11 +24,12 @@ Bidi2pdf gives you **precision, flexibility, and full control**.
|
|
|
24
24
|
7. [Architecture](#architecture)
|
|
25
25
|
8. [Docker](#docker)
|
|
26
26
|
9. [Configuration Options](#configuration-options)
|
|
27
|
-
10. [
|
|
28
|
-
11. [
|
|
29
|
-
12. [
|
|
30
|
-
13. [
|
|
31
|
-
14. [
|
|
27
|
+
10. [Programmatic Configuration](#programmatic-configuration)
|
|
28
|
+
11. [Rails Integration](#rails-integration)
|
|
29
|
+
12. [Test Helpers](#test-helpers)
|
|
30
|
+
13. [Development](#development)
|
|
31
|
+
14. [Contributing](#contributing)
|
|
32
|
+
15. [License](#license)
|
|
32
33
|
|
|
33
34
|
## ✨ Key Features
|
|
34
35
|
|
|
@@ -334,6 +335,68 @@ docker compose -f docker/docker-compose.yml down
|
|
|
334
335
|
|
|
335
336
|
---
|
|
336
337
|
|
|
338
|
+
## 🔧 Programmatic Configuration
|
|
339
|
+
|
|
340
|
+
Beyond the per-render CLI flags above, a few gem-wide defaults are set once via `Bidi2pdf.configure`:
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
Bidi2pdf.configure do |config|
|
|
344
|
+
config.default_timeout = 60 # seconds - default BiDi command timeout
|
|
345
|
+
config.enable_default_logging_subscriber = true
|
|
346
|
+
config.log_truncate_limit = 200 # bytes - see below
|
|
347
|
+
config.chromedriver_log_level = "WARNING" # see below
|
|
348
|
+
end
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
| Setting | Default | Description |
|
|
352
|
+
|-------------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
353
|
+
| `default_timeout` | `60` | Default timeout (seconds) for BiDi commands that don't specify their own. |
|
|
354
|
+
| `enable_default_logging_subscriber` | `true` | Subscribes a default logger to the gem's internal instrumentation events. |
|
|
355
|
+
| `log_truncate_limit` | `200` | Max bytes kept when logging a value that can be large (e.g. a `data:` URL) - truncated at a byte, not character, boundary. |
|
|
356
|
+
| `chromedriver_log_level` | `nil` | ChromeDriver's own `--log-level` (`"ALL"`/`"INFO"`/`"WARNING"`/`"SEVERE"`). Unset mirrors `Bidi2pdf.logger.level`; set explicitly to quiet ChromeDriver's own (often very verbose) output independently of your app's log level. |
|
|
357
|
+
|
|
358
|
+
`Bidi2pdf.logger`, `Bidi2pdf.network_events_logger`, `Bidi2pdf.browser_console_logger`, and
|
|
359
|
+
`Bidi2pdf.notification_service` are also configurable in the same block, for more advanced
|
|
360
|
+
logging/instrumentation needs.
|
|
361
|
+
|
|
362
|
+
### Pre-warmed sessions (`Bidi2pdf::SessionWarmer`)
|
|
363
|
+
|
|
364
|
+
Optional. Keeps a few Chrome sessions started in the background so a render skips browser startup.
|
|
365
|
+
Every slot is still used for exactly one render and then discarded - isolation is the same as
|
|
366
|
+
launching a fresh Chrome per PDF.
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
Bidi2pdf::SessionWarmer.configure do |c|
|
|
370
|
+
# warms c.size sessions right here, e.g. at boot
|
|
371
|
+
c.size = 2
|
|
372
|
+
c.max_idle_age = 300
|
|
373
|
+
# c.remote_browser_url = "http://remote-chrome:3000/session"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
Bidi2pdf::SessionWarmer.with_tab do |tab|
|
|
377
|
+
tab.navigate_to(url)
|
|
378
|
+
tab.print("invoice.pdf")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
Bidi2pdf::SessionWarmer.shutdown
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
| Setting | Default | Description |
|
|
385
|
+
|----------------------|-----------------------|-----------------------------------------------------------------------------------------------------------|
|
|
386
|
+
| `size` | `1` | Number of sessions kept warm. With none ready, a render starts its own session as usual - it never waits. |
|
|
387
|
+
| `max_idle_age` | `300` | Seconds a warm session may sit unused before it is retired and replaced. `nil` disables the limit. |
|
|
388
|
+
| `headless` | `true` | Run Chrome headless. |
|
|
389
|
+
| `chrome_args` | `DEFAULT_CHROME_ARGS` | Chrome launch arguments. |
|
|
390
|
+
| `remote_browser_url` | `nil` | Connect each slot to a remote chromedriver instead of starting a local one. |
|
|
391
|
+
|
|
392
|
+
> **Security note:** an idle warm session is an open, unauthenticated automation endpoint
|
|
393
|
+
> (chromedriver's port, Chrome's debugging port - loopback only for a local chromedriver) for as
|
|
394
|
+
> long as it waits. `max_idle_age` bounds that window; keep it set unless the process runs somewhere
|
|
395
|
+
> nothing else can reach those ports. With `remote_browser_url`, who can reach that endpoint on the
|
|
396
|
+
> network is what matters, exactly as it does without the warmer.
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
337
400
|
## 🚂 Rails Integration
|
|
338
401
|
|
|
339
402
|
Rails integration is available as an additional gem:
|
|
@@ -533,11 +533,21 @@ module Bidi2pdf
|
|
|
533
533
|
|
|
534
534
|
cmd = Bidi2pdf::Bidi::Commands::BrowsingContextNavigate.new url: url, context: browsing_context_id, wait: wait
|
|
535
535
|
|
|
536
|
-
client.send_cmd_and_wait(cmd) do |response|
|
|
537
|
-
|
|
536
|
+
navigation_id = client.send_cmd_and_wait(cmd) do |response|
|
|
537
|
+
logged_url = url.start_with?("data:") ? "data:[#{url.bytesize} bytes]" : url
|
|
538
|
+
response_navigation_id = response.dig("result", "navigation")
|
|
539
|
+
Bidi2pdf.logger.debug "Navigated to page url: #{logged_url} navigation: #{response_navigation_id}"
|
|
540
|
+
|
|
541
|
+
response_navigation_id
|
|
538
542
|
end
|
|
543
|
+
|
|
544
|
+
check_navigation_http_status(url, navigation_id)
|
|
539
545
|
rescue Bidi2pdf::CmdError => e
|
|
540
|
-
|
|
546
|
+
raise_navigation_error_for(url, e)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def raise_navigation_error_for(url, error)
|
|
550
|
+
msg = error.response["message"]
|
|
541
551
|
case msg
|
|
542
552
|
when /^net::ERR_INVALID_AUTH_CREDENTIALS/
|
|
543
553
|
raise NavigationAuthError.new(url, msg)
|
|
@@ -546,10 +556,41 @@ module Bidi2pdf
|
|
|
546
556
|
when /^net::/
|
|
547
557
|
raise NavigationError, "Connection error: #{url} #{msg}"
|
|
548
558
|
else
|
|
549
|
-
raise
|
|
559
|
+
raise error
|
|
550
560
|
end
|
|
551
561
|
end
|
|
552
562
|
|
|
563
|
+
# browsingContext.navigate's own response never carries an HTTP status - only a
|
|
564
|
+
# network.responseCompleted event does, correlated back to this specific navigation via its
|
|
565
|
+
# "navigation" field (a Chrome/redirect-chain-wide ID, not the network request's own id).
|
|
566
|
+
# register_event_listeners already ran above, so network_events has been tracking since
|
|
567
|
+
# before the navigate command was even sent. Deliberately conservative: with no navigation
|
|
568
|
+
# ID, or no correlated *completed* request found, this stays silent rather than guessing -
|
|
569
|
+
# only a positively confirmed >= 400 status raises. max_by(&:start_timestamp) picks the last
|
|
570
|
+
# hop of a redirect chain (every hop shares the same navigation ID), matching the page the
|
|
571
|
+
# browser actually ended up on.
|
|
572
|
+
def check_navigation_http_status(url, navigation_id)
|
|
573
|
+
return unless navigation_id
|
|
574
|
+
|
|
575
|
+
final_response = correlated_navigation_response(navigation_id)
|
|
576
|
+
return unless final_response
|
|
577
|
+
|
|
578
|
+
status = final_response.http_status_code
|
|
579
|
+
return if status < 400
|
|
580
|
+
|
|
581
|
+
raise NavigationNotFoundError, "Navigation to #{url} failed: HTTP 404 Not Found" if status == 404
|
|
582
|
+
|
|
583
|
+
raise NavigationError, "Navigation to #{url} failed: HTTP #{status}"
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# The last hop of a redirect chain (every hop shares the same navigation ID) - the page the
|
|
587
|
+
# browser actually ended up on, not wherever the chain started.
|
|
588
|
+
def correlated_navigation_response(navigation_id)
|
|
589
|
+
network_events.all_events
|
|
590
|
+
.select { |event| event.navigation == navigation_id && event.http_status_code }
|
|
591
|
+
.max_by(&:start_timestamp)
|
|
592
|
+
end
|
|
593
|
+
|
|
553
594
|
def register_event_listeners
|
|
554
595
|
return if @event_handlers_registered
|
|
555
596
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "websocket"
|
|
8
|
+
|
|
9
|
+
module Bidi2pdf
|
|
10
|
+
module Bidi
|
|
11
|
+
# A threaded WebSocket client on the `websocket` gem's framing - the same shape Selenium's Ruby
|
|
12
|
+
# BiDi client uses. It replaces websocket-client-simple, whose reader pulled one byte at a time
|
|
13
|
+
# (`getc`, then a frame-parse attempt per byte): a printed PDF comes back as one base64 message
|
|
14
|
+
# and every network event for a `data:` navigation echoes the whole URL, so that loop ran
|
|
15
|
+
# millions of times per render - ~730 ms to decode a 700 KB frame, against ~0.5 ms in 16 KB
|
|
16
|
+
# chunks.
|
|
17
|
+
#
|
|
18
|
+
# Emits :open, :message (a WebSocket frame, payload in #data), :error and :close.
|
|
19
|
+
class BufferedWebSocketClient
|
|
20
|
+
READ_CHUNK_BYTES = 16_384
|
|
21
|
+
|
|
22
|
+
attr_reader :url
|
|
23
|
+
|
|
24
|
+
def self.connect(url, options = {})
|
|
25
|
+
client = new
|
|
26
|
+
yield client if block_given?
|
|
27
|
+
client.connect url, options
|
|
28
|
+
client
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@listeners = Hash.new { |hash, event| hash[event] = [] }
|
|
33
|
+
@listeners_mutex = Mutex.new
|
|
34
|
+
@write_mutex = Mutex.new
|
|
35
|
+
# Re-entrant: a failed write inside #close closes again.
|
|
36
|
+
@close_monitor = Monitor.new
|
|
37
|
+
@handshaked = false
|
|
38
|
+
@closed = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def on(event, &listener)
|
|
42
|
+
@listeners_mutex.synchronize { @listeners[event] << listener }
|
|
43
|
+
listener
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def connect(url, options = {})
|
|
47
|
+
return if @socket
|
|
48
|
+
|
|
49
|
+
@url = url
|
|
50
|
+
@socket = open_socket(URI.parse(url), options)
|
|
51
|
+
::WebSocket.should_raise = true
|
|
52
|
+
@handshake = ::WebSocket::Handshake::Client.new url: url, headers: options[:headers]
|
|
53
|
+
|
|
54
|
+
socket = @socket # #close clears the ivar; the reader keeps its own reference
|
|
55
|
+
@thread = Thread.new { read_loop(socket, ::WebSocket::Frame::Incoming::Client.new) }
|
|
56
|
+
write @handshake.to_s
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Commands are sent from whichever thread issues them. On a plain TCP socket one IO#write is
|
|
60
|
+
# already atomic, but OpenSSL::SSL::SSLSocket#write is not, so writes share a lock for wss://.
|
|
61
|
+
def send(data, type: :text)
|
|
62
|
+
return unless open?
|
|
63
|
+
|
|
64
|
+
write ::WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version).to_s
|
|
65
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
66
|
+
close e
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Safe from any thread, including the reader's own: the reader sees the peer hang up and
|
|
70
|
+
# closes from inside its loop, so it must neither race a caller closing at the same moment
|
|
71
|
+
# nor be killed before :close has been emitted.
|
|
72
|
+
def close(error = nil)
|
|
73
|
+
@close_monitor.synchronize do
|
|
74
|
+
return if @closed
|
|
75
|
+
|
|
76
|
+
say_goodbye unless error
|
|
77
|
+
@closed = true
|
|
78
|
+
@socket&.close
|
|
79
|
+
@socket = nil
|
|
80
|
+
emit :close, error
|
|
81
|
+
@thread.kill if @thread && @thread != Thread.current
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def open? = @handshaked && !@closed
|
|
86
|
+
|
|
87
|
+
def closed? = @closed
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def emit(event, *)
|
|
92
|
+
@listeners_mutex.synchronize { @listeners[event].dup }.each { |listener| listener.call(*) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def write(bytes)
|
|
96
|
+
@write_mutex.synchronize { @socket&.write bytes }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def say_goodbye
|
|
100
|
+
write ::WebSocket::Frame::Outgoing::Client.new(data: nil, type: :close, version: @handshake.version).to_s if @handshaked
|
|
101
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
102
|
+
nil # the peer is already gone; there is nobody left to say goodbye to
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def open_socket(uri, options)
|
|
106
|
+
tcp_socket = TCPSocket.new(uri.host, uri.port || (uri.scheme == "wss" ? 443 : 80))
|
|
107
|
+
return tcp_socket unless %w[https wss].include?(uri.scheme)
|
|
108
|
+
|
|
109
|
+
::OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_context(options)).tap do |ssl_socket|
|
|
110
|
+
ssl_socket.sync_close = true
|
|
111
|
+
ssl_socket.hostname = uri.host
|
|
112
|
+
ssl_socket.connect
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def ssl_context(options)
|
|
117
|
+
::OpenSSL::SSL::SSLContext.new.tap do |context|
|
|
118
|
+
context.ssl_version = options[:ssl_version] if options[:ssl_version]
|
|
119
|
+
context.verify_mode = options[:verify_mode] if options[:verify_mode]
|
|
120
|
+
context.cert_store = (options[:cert_store] || ::OpenSSL::X509::Store.new).tap(&:set_default_paths)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def read_loop(socket, frame)
|
|
125
|
+
until @closed
|
|
126
|
+
begin
|
|
127
|
+
consume(socket.readpartial(READ_CHUNK_BYTES), frame)
|
|
128
|
+
# Same terminal-error list as #send/#say_goodbye - Errno::ECONNRESET is already a
|
|
129
|
+
# SystemCallError, so listing it separately was both redundant and, worse, incomplete: any
|
|
130
|
+
# *other* SystemCallError or OpenSSL::SSL::SSLError fell through to the generic rescue
|
|
131
|
+
# below, which only emits :error and loops back into readpartial - on a permanently broken
|
|
132
|
+
# socket (not just ECONNRESET) that re-raises the same error every iteration forever
|
|
133
|
+
# instead of ever closing.
|
|
134
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e # EOFError is an IOError
|
|
135
|
+
close e
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
emit :error, e
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def consume(chunk, frame)
|
|
143
|
+
if @handshaked
|
|
144
|
+
frame << chunk
|
|
145
|
+
else
|
|
146
|
+
@handshake << chunk
|
|
147
|
+
return unless @handshake.finished?
|
|
148
|
+
|
|
149
|
+
@handshaked = true
|
|
150
|
+
emit :open
|
|
151
|
+
# Anything the server sent straight after its handshake response is already in this chunk.
|
|
152
|
+
frame << @handshake.leftovers unless @handshake.leftovers.to_s.empty?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
while (message = frame.next)
|
|
156
|
+
dispatch_frame(message)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# WebSocket control frames (ping/pong/close) must never reach a JSON-parsing consumer -
|
|
161
|
+
# WebSocketDispatcher tries to parse every :message payload as JSON, so a raw close frame
|
|
162
|
+
# would raise JSON::ParserError, and an unanswered ping can make chromedriver or an
|
|
163
|
+
# intermediate proxy tear down an otherwise healthy connection on its own keepalive timeout.
|
|
164
|
+
def dispatch_frame(message)
|
|
165
|
+
case message.type
|
|
166
|
+
when :ping
|
|
167
|
+
send(message.data, type: :pong)
|
|
168
|
+
when :pong
|
|
169
|
+
nil
|
|
170
|
+
when :close
|
|
171
|
+
close
|
|
172
|
+
else
|
|
173
|
+
emit :message, message
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
data/lib/bidi2pdf/bidi/client.rb
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
require_relative "buffered_web_socket_client"
|
|
6
5
|
require_relative "web_socket_dispatcher"
|
|
7
6
|
require_relative "command_manager"
|
|
8
7
|
require_relative "connection_manager"
|
|
@@ -44,11 +43,11 @@ module Bidi2pdf
|
|
|
44
43
|
|
|
45
44
|
# Starts the WebSocket client and establishes a connection.
|
|
46
45
|
#
|
|
47
|
-
# @return [
|
|
46
|
+
# @return [BufferedWebSocketClient] The WebSocket connection object.
|
|
48
47
|
def start
|
|
49
48
|
return @socket if started?
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
BufferedWebSocketClient.connect(ws_url) do |socket|
|
|
52
51
|
@socket = socket
|
|
53
52
|
@command_manager = CommandManager.new(@socket)
|
|
54
53
|
|
|
@@ -62,11 +61,19 @@ module Bidi2pdf
|
|
|
62
61
|
@socket
|
|
63
62
|
end
|
|
64
63
|
|
|
65
|
-
# Checks if the WebSocket client has started.
|
|
64
|
+
# Checks if the WebSocket client has started. Unlike #open?, this never flips back to false on
|
|
65
|
+
# its own if the connection dies externally - it only reflects whether #start has run.
|
|
66
66
|
#
|
|
67
67
|
# @return [Boolean] True if the client has started, false otherwise.
|
|
68
68
|
def started? = @started
|
|
69
69
|
|
|
70
|
+
# Checks if the underlying WebSocket connection is actually open right now - false before
|
|
71
|
+
# #start, after #close, or if the reader thread noticed the connection died on its own (a
|
|
72
|
+
# real liveness signal, not just an internal "did we ever start" flag).
|
|
73
|
+
#
|
|
74
|
+
# @return [Boolean] True if the underlying socket reports itself open.
|
|
75
|
+
def open? = !!@socket&.open?
|
|
76
|
+
|
|
70
77
|
# Waits until the WebSocket connection is open.
|
|
71
78
|
#
|
|
72
79
|
# @param [Integer] timeout The timeout duration in seconds.
|
|
@@ -4,7 +4,7 @@ module Bidi2pdf
|
|
|
4
4
|
module Bidi
|
|
5
5
|
class NetworkEvent
|
|
6
6
|
attr_reader :id, :url, :state, :start_timestamp, :end_timestamp, :timing, :http_status_code,
|
|
7
|
-
:http_method, :bytes_received
|
|
7
|
+
:http_method, :bytes_received, :navigation
|
|
8
8
|
|
|
9
9
|
STATE_MAP = {
|
|
10
10
|
"network.responseStarted" => "started",
|
|
@@ -12,7 +12,13 @@ module Bidi2pdf
|
|
|
12
12
|
"network.fetchError" => "error"
|
|
13
13
|
}.freeze
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# @param [String, nil] navigation The BiDi navigation ID this request belongs to - present on
|
|
16
|
+
# the main-frame document request, letting a caller correlate a specific
|
|
17
|
+
# browsingContext.navigate call to the network event carrying its actual HTTP status. A
|
|
18
|
+
# sub-resource request (an image, a script, ...) has no real BiDi navigation ID and stays
|
|
19
|
+
# nil here - #id already distinguishes one event from another in logs, so this field is
|
|
20
|
+
# left alone rather than filled with a value the protocol never sent.
|
|
21
|
+
def initialize(id:, url:, timestamp:, timing:, state:, http_status_code: nil, http_method: nil, navigation: nil)
|
|
16
22
|
@id = id
|
|
17
23
|
@url = url
|
|
18
24
|
@start_timestamp = timestamp
|
|
@@ -20,6 +26,7 @@ module Bidi2pdf
|
|
|
20
26
|
@state = map_state(state)
|
|
21
27
|
@http_status_code = http_status_code
|
|
22
28
|
@http_method = http_method
|
|
29
|
+
@navigation = navigation
|
|
23
30
|
end
|
|
24
31
|
|
|
25
32
|
def update_state(new_state, timestamp: nil, timing: nil, http_status_code: nil, bytes_received: nil)
|
|
@@ -61,6 +68,7 @@ module Bidi2pdf
|
|
|
61
68
|
"method=#{method_str.inspect}, " \
|
|
62
69
|
"url=#{@url.inspect}, " \
|
|
63
70
|
"state=#{@state.inspect}, " \
|
|
71
|
+
"navigation=#{@navigation.inspect}, " \
|
|
64
72
|
"#{http_status}, " \
|
|
65
73
|
"bytes_received=#{bytes_str}, " \
|
|
66
74
|
"start=#{start_str}, " \
|
|
@@ -76,7 +84,8 @@ module Bidi2pdf
|
|
|
76
84
|
timing: @timing&.dup,
|
|
77
85
|
state: @state,
|
|
78
86
|
http_status_code: @http_status_code,
|
|
79
|
-
http_method: @http_method
|
|
87
|
+
http_method: @http_method,
|
|
88
|
+
navigation: @navigation
|
|
80
89
|
).tap do |duped|
|
|
81
90
|
duped.instance_variable_set(:@end_timestamp, @end_timestamp)
|
|
82
91
|
duped.instance_variable_set(:@bytes_received, @bytes_received)
|
|
@@ -41,6 +41,13 @@ module Bidi2pdf
|
|
|
41
41
|
timing = request["timings"]
|
|
42
42
|
http_method = request["method"]
|
|
43
43
|
|
|
44
|
+
# A top-level sibling of "request"/"response" in BiDi's own network.BaseParameters, not
|
|
45
|
+
# nested under either - present for the main-frame document request of a navigation, nil
|
|
46
|
+
# for an unrelated sub-resource fetch (an image, a script, ...). Lets a caller correlate a
|
|
47
|
+
# specific browsingContext.navigate call to the exact network event carrying its real HTTP
|
|
48
|
+
# status, which the navigate command's own response never includes.
|
|
49
|
+
navigation = event["navigation"]
|
|
50
|
+
|
|
44
51
|
timestamp = event["timestamp"]
|
|
45
52
|
|
|
46
53
|
Bidi2pdf.notification_service.instrument("network_event_received.bidi2pdf",
|
|
@@ -57,7 +64,8 @@ module Bidi2pdf
|
|
|
57
64
|
timestamp: timestamp,
|
|
58
65
|
timing: timing,
|
|
59
66
|
state: method,
|
|
60
|
-
http_method: http_method
|
|
67
|
+
http_method: http_method,
|
|
68
|
+
navigation: navigation
|
|
61
69
|
)
|
|
62
70
|
elsif events.key?(id)
|
|
63
71
|
events[id].update_state(method, timestamp: timestamp, timing: timing, http_status_code: http_status_code, bytes_received: bytes_received)
|
|
@@ -62,7 +62,7 @@ module Bidi2pdf
|
|
|
62
62
|
"--disable-breakpad", # Disable the Breakpad crash-reporting library entirely
|
|
63
63
|
"--enable-features=PdfOopif", # Enable out-of-process iframe (OOPIF) architecture for PDF rendering
|
|
64
64
|
"--disable-features=Translate,AcceptCHFrame,MediaRouter,OptimizationHints,ProcessPerSiteUpToMainFrameThreshold,IsolateSandboxedIframes",
|
|
65
|
-
"--disable-extensions about:blank"
|
|
65
|
+
"--disable-extensions" # Don't load extensions. One switch per entry: it used to read "--disable-extensions about:blank", which Chrome rejected whole ("Ignoring switch with invalid name").
|
|
66
66
|
].freeze
|
|
67
67
|
|
|
68
68
|
# @return [URI] The URI of the session.
|
|
@@ -194,13 +194,17 @@ module Bidi2pdf
|
|
|
194
194
|
cmd.join(" ")
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
+
# Chromedriver's own internal log verbosity is independent from this app's - defaults to
|
|
198
|
+
# mirroring Bidi2pdf.logger.level for backward compatibility, but Bidi2pdf.chromedriver_log_level
|
|
199
|
+
# lets a consumer quiet chromedriver's own (often much more verbose, e.g. full BiDi command/
|
|
200
|
+
# response dumps at its own INFO level) output without changing their app's own log level.
|
|
197
201
|
def chromedriver_log_level
|
|
198
|
-
case Bidi2pdf.logger.level
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
Bidi2pdf.chromedriver_log_level || case Bidi2pdf.logger.level
|
|
203
|
+
when Logger::DEBUG then "ALL"
|
|
204
|
+
when Logger::INFO then "INFO"
|
|
205
|
+
when Logger::WARN then "WARNING"
|
|
206
|
+
else "SEVERE"
|
|
207
|
+
end
|
|
204
208
|
end
|
|
205
209
|
|
|
206
210
|
def user_data_dir_arg
|