bidi2pdf 0.1.14 → 0.1.16

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +20 -0
  3. data/CHANGELOG.md +69 -2
  4. data/README.md +258 -10
  5. data/docker/Dockerfile +4 -0
  6. data/docker/Dockerfile.slim +5 -1
  7. data/lib/bidi2pdf/bidi/browser_tab.rb +45 -4
  8. data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
  9. data/lib/bidi2pdf/bidi/client.rb +12 -5
  10. data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
  11. data/lib/bidi2pdf/bidi/network_event.rb +12 -3
  12. data/lib/bidi2pdf/bidi/network_events.rb +9 -1
  13. data/lib/bidi2pdf/bidi/session.rb +1 -1
  14. data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
  15. data/lib/bidi2pdf/cli/json_output.rb +30 -0
  16. data/lib/bidi2pdf/cli.rb +559 -17
  17. data/lib/bidi2pdf/diagnose.rb +119 -0
  18. data/lib/bidi2pdf/error_codes.rb +59 -0
  19. data/lib/bidi2pdf/exit_codes.rb +44 -0
  20. data/lib/bidi2pdf/launcher.rb +23 -0
  21. data/lib/bidi2pdf/manifest.rb +65 -0
  22. data/lib/bidi2pdf/notifications/json_subscriber.rb +78 -0
  23. data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
  24. data/lib/bidi2pdf/pdf_inspection.rb +62 -0
  25. data/lib/bidi2pdf/recipe/loader.rb +44 -0
  26. data/lib/bidi2pdf/recipe/runner.rb +229 -0
  27. data/lib/bidi2pdf/recipe/schema_shape.rb +228 -0
  28. data/lib/bidi2pdf/recipe/validator.rb +138 -0
  29. data/lib/bidi2pdf/recipe.rb +83 -0
  30. data/lib/bidi2pdf/result.rb +67 -0
  31. data/lib/bidi2pdf/result_collector.rb +150 -0
  32. data/lib/bidi2pdf/schema.rb +382 -0
  33. data/lib/bidi2pdf/session_runner.rb +42 -0
  34. data/lib/bidi2pdf/session_warmer.rb +377 -0
  35. data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +4 -2
  36. data/lib/bidi2pdf/version.rb +1 -1
  37. data/lib/bidi2pdf.rb +114 -9
  38. data/sig/bidi2pdf/bidi/browser_tab.rbs +17 -0
  39. data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
  40. data/sig/bidi2pdf/bidi/client.rbs +10 -2
  41. data/sig/bidi2pdf/bidi/network_event.rbs +11 -1
  42. data/sig/bidi2pdf/bidi/session.rbs +1 -1
  43. data/sig/bidi2pdf/chromedriver_manager.rbs +4 -0
  44. data/sig/bidi2pdf/cli/json_output.rbs +19 -0
  45. data/sig/bidi2pdf/cli.rbs +112 -2
  46. data/sig/bidi2pdf/diagnose.rbs +30 -0
  47. data/sig/bidi2pdf/error_codes.rbs +21 -0
  48. data/sig/bidi2pdf/exit_codes.rbs +21 -0
  49. data/sig/bidi2pdf/launcher.rbs +9 -0
  50. data/sig/bidi2pdf/manifest.rbs +38 -0
  51. data/sig/bidi2pdf/notifications/json_subscriber.rbs +45 -0
  52. data/sig/bidi2pdf/pdf_inspection.rbs +33 -0
  53. data/sig/bidi2pdf/recipe/loader.rbs +20 -0
  54. data/sig/bidi2pdf/recipe/runner.rbs +92 -0
  55. data/sig/bidi2pdf/recipe/schema_shape.rbs +85 -0
  56. data/sig/bidi2pdf/recipe/validator.rbs +54 -0
  57. data/sig/bidi2pdf/recipe.rbs +72 -0
  58. data/sig/bidi2pdf/result.rbs +71 -0
  59. data/sig/bidi2pdf/result_collector.rbs +106 -0
  60. data/sig/bidi2pdf/schema.rbs +45 -0
  61. data/sig/bidi2pdf/session_runner.rbs +17 -0
  62. data/sig/bidi2pdf/session_warmer.rbs +221 -0
  63. data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +1 -1
  64. data/sig/bidi2pdf/version.rbs +1 -1
  65. data/sig/bidi2pdf.rbs +84 -0
  66. data/tasks/release_credentials_check.rake +97 -0
  67. metadata +39 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57c77db5483e79f4cfb2314b792fa10eecf910ee319319c8e838cce0c14dc390
4
- data.tar.gz: '029c81071a16b9c8b276afda6e162811552c095dac134d3f564497f1847840a0'
3
+ metadata.gz: 3533d126bc13406c70b6931112c07952aac0c19b718f3100258fb2182da8fcea
4
+ data.tar.gz: a761495802d5507fba3469405a571555547ed58891b216575f947e2b6a0c63be
5
5
  SHA512:
6
- metadata.gz: 2eabb11610a39980898f8f66109cc61561e47058815283d7f3db711a63ddf7e88b6bb57829dc2e39cb4f0c29811086986ff27b09c3655c8b4c1281dbfa91e1c8
7
- data.tar.gz: e7d8d0099c195d503593cd45fb7a62d1f94b67fd05bba6ef64181e6d239fed23fa0a9f5f934430e1b822389ce30b294f8805f0947f85dbbdfced6e4c71a5d95b
6
+ metadata.gz: cb29c3bf90a6a6dbd6fcf488980c51b9f666206ea18748d0c670e6fc2a64c453ca0f20043dce113094e74eb34d4b45f6be8b7d7a433522a59ac6ebab2d6ba8bb
7
+ data.tar.gz: bb9c384545c42d45630576b523c3c82025b1df2a2fc8975b8959276566173ad684b9423e7f30fc46b7834c61328ef68f9ddb1ee73d83b08e0ec8ea10b93f117f
data/.rubocop.yml CHANGED
@@ -1,6 +1,17 @@
1
+ # Keep RuboCop's own default excludes (vendor/**, tmp/**, node_modules/**, .git/**) when adding to
2
+ # AllCops/Exclude below - without this, a project-level Exclude replaces them and vendor/bundle gets
3
+ # linted.
4
+ inherit_mode:
5
+ merge:
6
+ - Exclude
7
+
1
8
  AllCops:
2
9
  NewCops: enable
3
10
  TargetRubyVersion: 3.4
11
+ Exclude:
12
+ # Local Claude devbox (gitignored). Its Dockerfile.ruby ends in ".ruby", which RuboCop takes for
13
+ # a Ruby source file and then fails to parse.
14
+ - 'docker-claude/**/*'
4
15
 
5
16
  Style/StringLiterals:
6
17
  EnforcedStyle: double_quotes
@@ -79,6 +90,15 @@ RSpec/DescribeClass:
79
90
  Exclude:
80
91
  - 'spec/acceptance/**/*_spec.rb'
81
92
 
93
+ # Matches bidi2pdf-rails' own spec/rails_helper.rb: scenario/when_ are custom
94
+ # alias_example_group_to groups there (and here, see spec/spec_helper.rb), not the single example
95
+ # Capybara's own `scenario` conventionally means - this cop doesn't know that and miscounts every
96
+ # then_/and_ nested underneath as belonging to one example.
97
+ RSpec/MultipleExpectations:
98
+ Enabled: true
99
+ Exclude:
100
+ - 'spec/acceptance/**/*_spec.rb'
101
+
82
102
  plugins:
83
103
  - rubocop-rake
84
104
  - rubocop-rspec
data/CHANGELOG.md CHANGED
@@ -8,10 +8,75 @@ 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.14..HEAD
11
+ [unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.16..HEAD
12
12
 
13
13
  <!-- generated by git-cliff end -->
14
14
 
15
+ ## [0.1.16] - 2026-09-22
16
+
17
+ ### 🐛 Fixed
18
+ - Give NavigationDNSError its own initializer
19
+ - Enforce the complete recipe shape in --validate, not just semantic checks
20
+ - Close schema/validator mismatches in recipe schema
21
+ - Document and address blank PDFs from Chrome's network checks
22
+ - Add browser tab cookie integration specs
23
+
24
+ ### 🔄 Changed
25
+ - Merge pull request #134 from dieter-medium/feat/llm-friendly-cli
26
+ - Merge pull request #133 from dieter-medium/feat/llm-friendly-cli
27
+ - Merge pull request #131 from dieter-medium/docs/local-network-access
28
+ - Merge pull request #130 from dieter-medium/fix/host-only-cookie-domain
29
+ - Merge pull request #126 from dieter-medium/chore-fix-release-creds-check
30
+
31
+ ### 🚀 Added
32
+ - Agent-friendly CLI - structured JSON, diagnose, and recipes
33
+
34
+ ## [0.1.15] - 2026-09-20
35
+
36
+ ### 🎨 Refactored
37
+
38
+ - Remove deprecated benchmarking script
39
+
40
+ ### 🐛 Fixed
41
+
42
+ - Handle partial Chrome session builds and cleanup
43
+ - Ensure consistent use of alias in nginx_url helper
44
+ - Use AtomicFixnum for thread-safe slot counting
45
+ - Refactor Chrome session setup in specs
46
+ - Improve session warmer configuration and timeout handling
47
+ - Decouple chromedriver log level from logger level
48
+ - Remove navigation ID generation for sub-resources
49
+ - Remove redundant timeout error test
50
+ - Improve string truncation for log safety
51
+ - Improve release credentials check for missing keys
52
+ - Enhance WebSocket handling for control frames and errors
53
+ - Add chromedriver log level configuration
54
+ - Adjust benchmark thresholds for CI environments
55
+ - Add string truncation for safe log output
56
+ - Set timeout for RSpec test runs
57
+ - Prevent logging raw data URLs during navigation
58
+ - Generate unique navigation IDs for sub-resource requests
59
+
60
+ ### 🔄 Changed
61
+
62
+ - Merge pull request #124 from dieter-medium/feat/session-warmer
63
+ - Merge pull request #123 from dieter-medium/feat/faster-pdf-generation
64
+ - Merge pull request #122 from dieter-medium/chore-prepare-dev
65
+ - Bump version to 0.1.15.pre
66
+
67
+ ### 🚀 Added
68
+
69
+ - Implement session warmer idle time management
70
+ - Improve session warmer threading and recovery
71
+ - Enhance session warming and WebSocket health checks
72
+ - Add session warmer and enhance version resolver error handling
73
+ - Add session warming and programmatic configuration
74
+ - Add chromedriver log level mapping and tests
75
+ - Add release credential check for rubygems.org
76
+ - Add navigation id handling in network events
77
+ - Replace WebSocket client library for performance
78
+ - Implement BufferedWebSocketClient and benchmarks
79
+
15
80
  ## [0.1.14] - 2026-09-19
16
81
 
17
82
  ### 🐛 Fixed
@@ -407,7 +472,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
407
472
 
408
473
  ### 🔄 Released
409
474
 
410
- - [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.14..HEAD)
475
+ - [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.16..HEAD)
476
+ - [0.1.16](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.15..v0.1.16)
477
+ - [0.1.15](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.14..v0.1.15)
411
478
  - [0.1.14](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.13..v0.1.14)
412
479
  - [0.1.13](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.12..v0.1.13)
413
480
  - [0.1.12](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.11..v0.1.12)
data/README.md CHANGED
@@ -20,15 +20,17 @@ Bidi2pdf gives you **precision, flexibility, and full control**.
20
20
  3. [Why BiDi?](#why-bidi-instead-of-cdp)
21
21
  4. [Installation](#installation)
22
22
  5. [CLI Usage](#cli-usage)
23
- 6. [Library API](#library-api)
24
- 7. [Architecture](#architecture)
25
- 8. [Docker](#docker)
26
- 9. [Configuration Options](#configuration-options)
27
- 10. [Rails Integration](#rails-integration)
28
- 11. [Test Helpers](#test-helpers)
29
- 12. [Development](#development)
30
- 13. [Contributing](#contributing)
31
- 14. [License](#license)
23
+ 6. [Agent and Automation Usage](#agent-and-automation-usage)
24
+ 7. [Library API](#library-api)
25
+ 8. [Architecture](#architecture)
26
+ 9. [Docker](#docker)
27
+ 10. [Configuration Options](#configuration-options)
28
+ 11. [Programmatic Configuration](#programmatic-configuration)
29
+ 12. [Rails Integration](#rails-integration)
30
+ 13. [Test Helpers](#test-helpers)
31
+ 14. [Development](#development)
32
+ 15. [Contributing](#contributing)
33
+ 16. [License](#license)
32
34
 
33
35
  ## ✨ Key Features
34
36
 
@@ -39,7 +41,9 @@ Bidi2pdf gives you **precision, flexibility, and full control**.
39
41
  ✅ **Docker-ready** – Plug and play with containers
40
42
  ✅ **Modern architecture** – Built on Chrome's next-gen BiDi protocol
41
43
  ✅ **Network logging** – Know which requests fail during rendering
42
- ✅ **Console log capture** – See what goes wrong inside the browser
44
+ ✅ **Console log capture** – See what goes wrong inside the browser
45
+ ✅ **Agent-ready** – Structured JSON output, NDJSON progress streaming, a page diagnostic, and
46
+ declarative recipes, so LLM agents and CI can drive it without parsing logs
43
47
 
44
48
  ---
45
49
 
@@ -108,6 +112,152 @@ bidi2pdf render \
108
112
 
109
113
  ---
110
114
 
115
+ ## 🤖 Agent and Automation Usage
116
+
117
+ Every command below also works without `--json` (human-readable output on stdout); with it, stdout
118
+ carries exactly one JSON document and nothing else - safe to pipe into `jq` or parse directly.
119
+ Human-readable logs move to stderr for the duration of any `--json`/`--output -` call, never
120
+ mixing into stdout. `--json-stream` works independently of the two: it always writes progress
121
+ events to stderr, whether or not `--json` is also given - in human mode, that just means
122
+ human-readable output keeps going to stdout as usual, alongside the stream. Full JSON Schema for
123
+ every shape is built into the gem, so an agent can discover it without reading this file:
124
+
125
+ ```bash
126
+ # 1. Check compatibility first - no browser launched
127
+ bidi2pdf version --json
128
+
129
+ # 2. Discover the shape of each command's own result, a manifest, an NDJSON event, or a recipe file
130
+ bidi2pdf schema render
131
+ bidi2pdf schema diagnose
132
+ bidi2pdf schema run
133
+ bidi2pdf schema manifest
134
+ bidi2pdf schema event
135
+ bidi2pdf schema recipe
136
+
137
+ # 3. Validate a recipe - no browser launched, the cheapest way to iterate on one
138
+ bidi2pdf run recipe.yml --validate
139
+
140
+ # 4. Run it for real
141
+ bidi2pdf run recipe.yml --json
142
+ ```
143
+
144
+ ### Structured render output
145
+
146
+ ```bash
147
+ bidi2pdf render --url https://example.com/invoice/14432423 --output example.pdf --json
148
+ ```
149
+
150
+ ```json
151
+ {
152
+ "schema_version": 1,
153
+ "ok": true,
154
+ "command": "render",
155
+ "output": "example.pdf",
156
+ "bytes": 182734,
157
+ "sha256": "abcd...",
158
+ "pages": 2,
159
+ "duration_ms": 842,
160
+ "navigation": { "requested_url": "https://example.com/invoice/14432423", "final_url": "https://example.com/invoice/14432423", "status": 200 },
161
+ "console": [],
162
+ "network_failures": [],
163
+ "warnings": [],
164
+ "error": null
165
+ }
166
+ ```
167
+
168
+ `pages` is `null`, with a warning explaining why, when the optional `pdf-reader` gem isn't
169
+ installed - see [Docker](#docker) for why the published images always have it. A failed render
170
+ still emits exactly this shape, with `ok: false` and a structured `error` (`code`, `message`,
171
+ `retryable`, `hint`, `details`) instead of a stack trace:
172
+
173
+ | Exit code | Meaning |
174
+ |---|---|
175
+ | `0` | success |
176
+ | `2` | CLI, configuration, or recipe-validation error |
177
+ | `3` | browser or navigation error |
178
+ | `4` | page not as expected (a recipe action/assertion, or a diagnose selector, failed) |
179
+ | `6` | output or PDF generation failure |
180
+ | `70` | unexpected internal error |
181
+
182
+ ### stdin/stdout, manifests, and progress streaming
183
+
184
+ ```bash
185
+ # Render HTML piped on stdin, PDF bytes piped out on stdout - no files touched
186
+ cat page.html | bidi2pdf render --stdin --output - > page.pdf
187
+
188
+ # A render manifest: enough to reproduce and diagnose the render later
189
+ bidi2pdf render --url https://example.com --output example.pdf --manifest render.json
190
+
191
+ # One JSON progress event per stderr line as the render happens - works with human-readable
192
+ # output too, not just --json
193
+ bidi2pdf render --url https://example.com --output example.pdf --json-stream
194
+ ```
195
+
196
+ ### Diagnosing a page before trusting its PDF
197
+
198
+ `bidi2pdf diagnose` loads a page like `render` does but produces no PDF - it answers *why does the
199
+ PDF not look like the page* (console errors, failed requests, font-loading status, `@media
200
+ print`/`@page` rules, fixed/sticky elements, Paged.js detection), not what the page's content is:
201
+
202
+ ```bash
203
+ bidi2pdf diagnose --url https://example.com/invoice/14432423 --json
204
+ ```
205
+
206
+ ### Declarative recipes
207
+
208
+ A recipe is a rendering contract - the waits a page needs before it's printed, and the properties
209
+ the resulting PDF must have - not a general browser-automation script:
210
+
211
+ ```yaml
212
+ # invoice.yml
213
+ version: 1
214
+
215
+ source:
216
+ url: https://example.com/invoice/123
217
+
218
+ actions:
219
+ - wait_for:
220
+ selector: "#invoice"
221
+ timeout: 10
222
+ - click:
223
+ selector: "#show-details"
224
+ - wait_network_idle:
225
+ timeout: 10
226
+
227
+ assert:
228
+ - selector_exists:
229
+ selector: "#total"
230
+ - no_console_errors: true
231
+ - page_count: 2
232
+ - pdf_text_present:
233
+ text: "Invoice #123"
234
+
235
+ output:
236
+ pdf: invoice.pdf
237
+ manifest: invoice.json
238
+ ```
239
+
240
+ ```bash
241
+ bidi2pdf run invoice.yml --validate # schema + known actions/assertions, no browser
242
+ bidi2pdf run invoice.yml --json # actions, then the PDF, then assertions against it
243
+ ```
244
+
245
+ Actions (`wait_for`, `click`, `evaluate`, `inject_script`, `inject_style`, `set_viewport`,
246
+ `wait_network_idle`) and page assertions (`selector_exists`, `text_present`, `no_console_errors`,
247
+ `no_network_failures`, `fonts_loaded`) are a thin layer over the same `BrowserTab` methods the
248
+ [Programmatic API](#-programmatic-api) below uses directly. PDF assertions (`page_count`,
249
+ `pdf_text_present`, `pdf_not_blank`) need the `pdf-reader` gem - a recipe using one fails
250
+ `--validate` immediately, before any browser launches, when it isn't installed.
251
+
252
+ `no_console_errors`, `no_network_failures`, `fonts_loaded`, and `pdf_not_blank` are presence-only
253
+ assertions - the step is either there or it isn't, nothing reads the value beside it - so they must
254
+ be written as `true` exactly, e.g. `- no_console_errors: true`; `false` (or any other value) is
255
+ rejected by both `bidi2pdf schema recipe` and `--validate` rather than being silently ignored.
256
+ `wait_for` needs exactly one of `selector`, `paged_js`, `script` - zero or more than one is
257
+ rejected the same way, before any browser launches.
258
+
259
+ ---
260
+
111
261
  ## 🧠 Programmatic API
112
262
 
113
263
  ### Classic Approach
@@ -285,6 +435,11 @@ docker run -it --rm \
285
435
 
286
436
  ✅ Tip: Mount your local directory (e.g. ./output) to /reports in the container to easily access the generated PDFs.
287
437
 
438
+ ✅ Both published images also install [`pdf-reader`](https://github.com/yob/pdf-reader) - not a
439
+ runtime dependency of the gem itself (see [Agent and Automation Usage](#agent-and-automation-usage)) -
440
+ so `pages` and the `page_count`/`pdf_text_present`/`pdf_not_blank` recipe assertions work out of
441
+ the box in either image, no extra install step needed.
442
+
288
443
  ### Docker Compose
289
444
 
290
445
  ```bash
@@ -331,6 +486,99 @@ docker compose -f docker/docker-compose.yml down
331
486
  | `--log_level` | Log level: debug, info, warn, error, fatal |
332
487
  | `--remote_browser_url` | Connect to remote Chrome session |
333
488
  | `--default_timeout` | Operation timeout (default: 60s) |
489
+ | `--json` | Emit a single structured JSON result document (see `bidi2pdf schema render`) |
490
+ | `--json_stream` | Emit one JSON progress event per line to stderr (see `bidi2pdf schema event`) - works with or without `--json` |
491
+ | `--stdin` | Read the HTML document from stdin, instead of `--url`/`--html-file` |
492
+ | `--output -` | Write raw PDF bytes to stdout instead of a file |
493
+ | `--manifest FILE` | Write a render manifest (see `bidi2pdf schema manifest`) to `FILE` |
494
+
495
+ See [Agent and Automation Usage](#agent-and-automation-usage) above for `bidi2pdf diagnose`,
496
+ `bidi2pdf run recipe.yml`, `bidi2pdf schema <kind>`, and `bidi2pdf version --json` - a separate
497
+ set of commands with their own options, not additional flags on `render`.
498
+
499
+ ---
500
+
501
+ ## 🔧 Programmatic Configuration
502
+
503
+ Beyond the per-render CLI flags above, a few gem-wide defaults are set once via `Bidi2pdf.configure`:
504
+
505
+ ```ruby
506
+ Bidi2pdf.configure do |config|
507
+ config.default_timeout = 60 # seconds - default BiDi command timeout
508
+ config.enable_default_logging_subscriber = true
509
+ config.log_truncate_limit = 200 # bytes - see below
510
+ config.chromedriver_log_level = "WARNING" # see below
511
+ end
512
+ ```
513
+
514
+ | Setting | Default | Description |
515
+ |-------------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
516
+ | `default_timeout` | `60` | Default timeout (seconds) for BiDi commands that don't specify their own. |
517
+ | `enable_default_logging_subscriber` | `true` | Subscribes a default logger to the gem's internal instrumentation events. |
518
+ | `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. |
519
+ | `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. |
520
+
521
+ `Bidi2pdf.logger`, `Bidi2pdf.network_events_logger`, `Bidi2pdf.browser_console_logger`, and
522
+ `Bidi2pdf.notification_service` are also configurable in the same block, for more advanced
523
+ logging/instrumentation needs.
524
+
525
+ ### Pre-warmed sessions (`Bidi2pdf::SessionWarmer`)
526
+
527
+ Optional. Keeps a few Chrome sessions started in the background so a render skips browser startup.
528
+ Every slot is still used for exactly one render and then discarded - isolation is the same as
529
+ launching a fresh Chrome per PDF.
530
+
531
+ ```ruby
532
+ Bidi2pdf::SessionWarmer.configure do |c|
533
+ # warms c.size sessions right here, e.g. at boot
534
+ c.size = 2
535
+ c.max_idle_age = 300
536
+ # c.remote_browser_url = "http://remote-chrome:3000/session"
537
+ end
538
+
539
+ Bidi2pdf::SessionWarmer.with_tab do |tab|
540
+ tab.navigate_to(url)
541
+ tab.print("invoice.pdf")
542
+ end
543
+
544
+ Bidi2pdf::SessionWarmer.shutdown
545
+ ```
546
+
547
+ | Setting | Default | Description |
548
+ |----------------------|-----------------------|-----------------------------------------------------------------------------------------------------------|
549
+ | `size` | `1` | Number of sessions kept warm. With none ready, a render starts its own session as usual - it never waits. |
550
+ | `max_idle_age` | `300` | Seconds a warm session may sit unused before it is retired and replaced. `nil` disables the limit. |
551
+ | `headless` | `true` | Run Chrome headless. |
552
+ | `chrome_args` | `DEFAULT_CHROME_ARGS` | Chrome launch arguments. |
553
+ | `remote_browser_url` | `nil` | Connect each slot to a remote chromedriver instead of starting a local one. |
554
+
555
+ > **Security note:** an idle warm session is an open, unauthenticated automation endpoint
556
+ > (chromedriver's port, Chrome's debugging port - loopback only for a local chromedriver) for as
557
+ > long as it waits. `max_idle_age` bounds that window; keep it set unless the process runs somewhere
558
+ > nothing else can reach those ports. With `remote_browser_url`, who can reach that endpoint on the
559
+ > network is what matters, exactly as it does without the warmer.
560
+
561
+ ### Customizing Chrome arguments (and blank PDFs from inline HTML)
562
+
563
+ `Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS` already contains one `--disable-features=...` entry,
564
+ and Chrome only honors the **last** occurrence of that switch. To turn another feature off, extend
565
+ that entry instead of appending a second switch, which would silently drop the defaults:
566
+
567
+ ```ruby
568
+ chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.map do |arg|
569
+ arg.start_with?("--disable-features=") ? "#{arg},LocalNetworkAccessChecks" : arg
570
+ end
571
+ ```
572
+
573
+ `LocalNetworkAccessChecks` is the one you are most likely to need. HTML rendered through
574
+ `BrowserTab#render_html_content` is loaded as a `data:` URL, which Chrome treats as a public origin;
575
+ if that HTML references assets on a private address (`localhost`, a Docker hostname, ...), recent
576
+ Chrome versions (confirmed with Chrome 153) block those requests before they are sent. Nothing
577
+ raises - the assets show up as failed network events, the server never sees a request, and the PDF
578
+ comes out unstyled or blank. Disable the check only where the asset host really is private, and keep
579
+ it on when rendering pages you do not control.
580
+ The [bidi2pdf-rails README](https://github.com/dieter-medium/bidi2pdf-rails#readme) has the full
581
+ walkthrough under "Blank PDFs: Chrome's Local Network Access Check".
334
582
 
335
583
  ---
336
584
 
data/docker/Dockerfile CHANGED
@@ -34,7 +34,11 @@ WORKDIR /app
34
34
  # Copy your gem into container
35
35
  COPY ./pkg/bidi2pdf-*.gem ./
36
36
 
37
+ # pdf-reader is deliberately not a bidi2pdf runtime dependency - it is installed here, as its own
38
+ # step, so the published image always has `pages`/PDF assertions available, the environment an
39
+ # agent or CI job actually renders in.
37
40
  RUN gem install ./bidi2pdf-*.gem && \
41
+ gem install pdf-reader -v "~> 2.14" && \
38
42
  chown -R appuser:appuser /app
39
43
 
40
44
  # Switch to non-root user
@@ -26,7 +26,11 @@ WORKDIR /app
26
26
  # Copy your gem into container
27
27
  COPY ./pkg/bidi2pdf-*.gem ./
28
28
 
29
- RUN gem install ./bidi2pdf-*.gem
29
+ # pdf-reader is deliberately not a bidi2pdf runtime dependency - it is installed here, as its own
30
+ # step, so the published image always has `pages`/PDF assertions available, the environment an
31
+ # agent or CI job actually renders in.
32
+ RUN gem install ./bidi2pdf-*.gem && \
33
+ gem install pdf-reader -v "~> 2.14"
30
34
 
31
35
 
32
36
  # Stage 2
@@ -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
- Bidi2pdf.logger.debug "Navigated to page url: #{url} response: #{response}"
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
- msg = e.response["message"]
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 e
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