bidi2pdf 0.1.15 → 0.1.17

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +20 -0
  3. data/CHANGELOG.md +46 -2
  4. data/README.md +233 -11
  5. data/docker/Dockerfile +11 -0
  6. data/docker/Dockerfile.chromedriver +7 -0
  7. data/docker/Dockerfile.slim +12 -1
  8. data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +9 -0
  9. data/lib/bidi2pdf/bidi/session.rb +5 -1
  10. data/lib/bidi2pdf/cli/json_output.rb +30 -0
  11. data/lib/bidi2pdf/cli.rb +559 -17
  12. data/lib/bidi2pdf/diagnose.rb +119 -0
  13. data/lib/bidi2pdf/error_codes.rb +59 -0
  14. data/lib/bidi2pdf/exit_codes.rb +44 -0
  15. data/lib/bidi2pdf/launcher.rb +23 -0
  16. data/lib/bidi2pdf/manifest.rb +65 -0
  17. data/lib/bidi2pdf/notifications/json_subscriber.rb +78 -0
  18. data/lib/bidi2pdf/pdf_inspection.rb +62 -0
  19. data/lib/bidi2pdf/recipe/loader.rb +44 -0
  20. data/lib/bidi2pdf/recipe/runner.rb +229 -0
  21. data/lib/bidi2pdf/recipe/schema_shape.rb +228 -0
  22. data/lib/bidi2pdf/recipe/validator.rb +138 -0
  23. data/lib/bidi2pdf/recipe.rb +83 -0
  24. data/lib/bidi2pdf/result.rb +67 -0
  25. data/lib/bidi2pdf/result_collector.rb +150 -0
  26. data/lib/bidi2pdf/schema.rb +382 -0
  27. data/lib/bidi2pdf/session_registry.rb +97 -0
  28. data/lib/bidi2pdf/session_runner.rb +42 -0
  29. data/lib/bidi2pdf/session_sweeper.rb +64 -0
  30. data/lib/bidi2pdf/session_warmer.rb +51 -1
  31. data/lib/bidi2pdf/version.rb +1 -1
  32. data/lib/bidi2pdf.rb +92 -8
  33. data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +7 -0
  34. data/sig/bidi2pdf/bidi/session.rbs +6 -0
  35. data/sig/bidi2pdf/cli/json_output.rbs +19 -0
  36. data/sig/bidi2pdf/cli.rbs +112 -2
  37. data/sig/bidi2pdf/diagnose.rbs +30 -0
  38. data/sig/bidi2pdf/error_codes.rbs +21 -0
  39. data/sig/bidi2pdf/exit_codes.rbs +21 -0
  40. data/sig/bidi2pdf/launcher.rbs +9 -0
  41. data/sig/bidi2pdf/manifest.rbs +38 -0
  42. data/sig/bidi2pdf/notifications/json_subscriber.rbs +45 -0
  43. data/sig/bidi2pdf/pdf_inspection.rbs +33 -0
  44. data/sig/bidi2pdf/recipe/loader.rbs +20 -0
  45. data/sig/bidi2pdf/recipe/runner.rbs +92 -0
  46. data/sig/bidi2pdf/recipe/schema_shape.rbs +85 -0
  47. data/sig/bidi2pdf/recipe/validator.rbs +54 -0
  48. data/sig/bidi2pdf/recipe.rbs +72 -0
  49. data/sig/bidi2pdf/result.rbs +71 -0
  50. data/sig/bidi2pdf/result_collector.rbs +106 -0
  51. data/sig/bidi2pdf/schema.rbs +45 -0
  52. data/sig/bidi2pdf/session_registry.rbs +44 -0
  53. data/sig/bidi2pdf/session_runner.rbs +17 -0
  54. data/sig/bidi2pdf/session_sweeper.rbs +36 -0
  55. data/sig/bidi2pdf/session_warmer.rbs +33 -0
  56. data/sig/bidi2pdf/version.rbs +1 -1
  57. data/sig/bidi2pdf.rbs +66 -0
  58. metadata +43 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36afd0d1f8c64ff6d72a8051785d3f65a7efcb336c07e1a6756b772ff4f17414
4
- data.tar.gz: 6ecddfbacf09de3757b65009e1c41ca3cb90b8667d2fe80972200159d9f0c2b3
3
+ metadata.gz: b879e9c84ccd1584f5b2eb1b406972624cd8b64a50c92466e78f7275ef229441
4
+ data.tar.gz: 50d517dac875995c2a06ca39dcd8c521c0b18fd238b6950d5e0d9674187941dd
5
5
  SHA512:
6
- metadata.gz: '0490c1e9e84467c4437eaf65128cf9adddac4509a66e7e41a791f10cb5fee6ee86d5fb821177af022a04a41441c6c57a37e8015e6a288c5e6838ecbc966bb628'
7
- data.tar.gz: 935185356734aded9f38453cb2e6340501d1f99c482ffc17d62201867dd95224fe7018dfc079cfbdeed72ea44c63233fb5769987ddaa339ed7425ee6b0e281f5
6
+ metadata.gz: 7fca9af905a088093ff4c8ac3a1c78b4f6138068c27ad4cf87616c7ded67f44b3d57aab73b40d83b6c9d8d63097f2851b10f5b0531a91bc22088ec15aab21769
7
+ data.tar.gz: 835727303208ada398c0ec7456e29d49201d2b798a34edac35007c40ae5123e2a382a6e325e978237fd328ef0d58e6f573d61b1447df54bae51f9188775ab4c6
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,52 @@ 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.15..HEAD
11
+ [unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.17..HEAD
12
12
 
13
13
  <!-- generated by git-cliff end -->
14
14
 
15
+ ## [0.1.17] - 2026-09-29
16
+
17
+ ### 🐛 Fixed
18
+ - Let Chromium start on a read-only root
19
+ - Do not raise when our own close ends a write
20
+
21
+ ### 📝 Docs
22
+ - Describe latest as the newest Chromium build
23
+
24
+ ### 🔄 Changed
25
+ - Merge pull request #143 from dieter-medium/feat/chromedriver-sha-tags
26
+ - Merge pull request #140 from dieter-medium/fix/chromium-read-only-root
27
+ - Merge pull request #139 from dieter-medium/fix/websocket-close-during-write
28
+ - Merge pull request #138 from dieter-medium/feat/session-warmer-orphan-sweep
29
+ - Merge pull request #132 from dieter-medium/dependabot/bundler/main/rubyzip-3.6.0
30
+
31
+ ### 🔧 Build
32
+ - Update rubyzip requirement from ~> 2.4 to >= 2.4, < 4.0
33
+
34
+ ### 🚀 Added
35
+ - Tag every chromedriver image build by commit
36
+ - Close leftover warm sessions on start
37
+
38
+ ## [0.1.16] - 2026-09-22
39
+
40
+ ### 🐛 Fixed
41
+ - Give NavigationDNSError its own initializer
42
+ - Enforce the complete recipe shape in --validate, not just semantic checks
43
+ - Close schema/validator mismatches in recipe schema
44
+ - Document and address blank PDFs from Chrome's network checks
45
+ - Add browser tab cookie integration specs
46
+
47
+ ### 🔄 Changed
48
+ - Merge pull request #134 from dieter-medium/feat/llm-friendly-cli
49
+ - Merge pull request #133 from dieter-medium/feat/llm-friendly-cli
50
+ - Merge pull request #131 from dieter-medium/docs/local-network-access
51
+ - Merge pull request #130 from dieter-medium/fix/host-only-cookie-domain
52
+ - Merge pull request #126 from dieter-medium/chore-fix-release-creds-check
53
+
54
+ ### 🚀 Added
55
+ - Agent-friendly CLI - structured JSON, diagnose, and recipes
56
+
15
57
  ## [0.1.15] - 2026-09-20
16
58
 
17
59
  ### 🎨 Refactored
@@ -453,7 +495,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
453
495
 
454
496
  ### 🔄 Released
455
497
 
456
- - [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.15..HEAD)
498
+ - [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.17..HEAD)
499
+ - [0.1.17](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.16..v0.1.17)
500
+ - [0.1.16](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.15..v0.1.16)
457
501
  - [0.1.15](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.14..v0.1.15)
458
502
  - [0.1.14](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.13..v0.1.14)
459
503
  - [0.1.13](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.12..v0.1.13)
data/README.md CHANGED
@@ -20,16 +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. [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)
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)
33
34
 
34
35
  ## ✨ Key Features
35
36
 
@@ -40,7 +41,9 @@ Bidi2pdf gives you **precision, flexibility, and full control**.
40
41
  ✅ **Docker-ready** – Plug and play with containers
41
42
  ✅ **Modern architecture** – Built on Chrome's next-gen BiDi protocol
42
43
  ✅ **Network logging** – Know which requests fail during rendering
43
- ✅ **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
44
47
 
45
48
  ---
46
49
 
@@ -109,6 +112,152 @@ bidi2pdf render \
109
112
 
110
113
  ---
111
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
+
112
261
  ## 🧠 Programmatic API
113
262
 
114
263
  ### Classic Approach
@@ -286,6 +435,30 @@ docker run -it --rm \
286
435
 
287
436
  ✅ Tip: Mount your local directory (e.g. ./output) to /reports in the container to easily access the generated PDFs.
288
437
 
438
+ ✅ All images run with a read-only root filesystem (`--read-only`) as long as `/tmp` is writable,
439
+ e.g. `--tmpfs /tmp`: they point Chromium's `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` there, without
440
+ which Chromium's crash handler fails to start and every launch aborts with
441
+ `chrome_crashpad_handler: --database is required`.
442
+
443
+ ✅ Both published images also install [`pdf-reader`](https://github.com/yob/pdf-reader) - not a
444
+ runtime dependency of the gem itself (see [Agent and Automation Usage](#agent-and-automation-usage)) -
445
+ so `pages` and the `page_count`/`pdf_text_present`/`pdf_not_blank` recipe assertions work out of
446
+ the box in either image, no extra install step needed.
447
+
448
+ ### ChromeDriver image tags
449
+
450
+ [`dieters877565/chromedriver`](https://hub.docker.com/r/dieters877565/chromedriver) is built for
451
+ `linux/amd64` and `linux/arm64` with these tags:
452
+
453
+ | Tag | Moves? | Use |
454
+ |---|---|---|
455
+ | `0.1.17` (a release) | no | a fixed Chromium, pinned together with the gem version |
456
+ | `sha-<short commit>` | only if that commit is built again by hand | a fix on `main` not released yet |
457
+ | `latest`, `main` | yes, every push to `main` | the newest build and its Chromium security fixes |
458
+
459
+ Chromium comes from Debian's packages at build time, so every build can carry a different
460
+ Chromium - for a byte-exact pin use the digest (`docker buildx imagetools inspect <image:tag>`).
461
+
289
462
  ### Docker Compose
290
463
 
291
464
  ```bash
@@ -332,6 +505,15 @@ docker compose -f docker/docker-compose.yml down
332
505
  | `--log_level` | Log level: debug, info, warn, error, fatal |
333
506
  | `--remote_browser_url` | Connect to remote Chrome session |
334
507
  | `--default_timeout` | Operation timeout (default: 60s) |
508
+ | `--json` | Emit a single structured JSON result document (see `bidi2pdf schema render`) |
509
+ | `--json_stream` | Emit one JSON progress event per line to stderr (see `bidi2pdf schema event`) - works with or without `--json` |
510
+ | `--stdin` | Read the HTML document from stdin, instead of `--url`/`--html-file` |
511
+ | `--output -` | Write raw PDF bytes to stdout instead of a file |
512
+ | `--manifest FILE` | Write a render manifest (see `bidi2pdf schema manifest`) to `FILE` |
513
+
514
+ See [Agent and Automation Usage](#agent-and-automation-usage) above for `bidi2pdf diagnose`,
515
+ `bidi2pdf run recipe.yml`, `bidi2pdf schema <kind>`, and `bidi2pdf version --json` - a separate
516
+ set of commands with their own options, not additional flags on `render`.
335
517
 
336
518
  ---
337
519
 
@@ -388,6 +570,24 @@ Bidi2pdf::SessionWarmer.shutdown
388
570
  | `headless` | `true` | Run Chrome headless. |
389
571
  | `chrome_args` | `DEFAULT_CHROME_ARGS` | Chrome launch arguments. |
390
572
  | `remote_browser_url` | `nil` | Connect each slot to a remote chromedriver instead of starting a local one. |
573
+ | `orphan_age` | `:auto` | Remote only: on start, close sessions other warmers left behind older than this (`:auto` = 2 × `max_idle_age`, `nil` = off). |
574
+ | `registry_dir` | `Dir.tmpdir` | Where the session registry file lives - every process that should clean up after the others must share it. |
575
+
576
+ #### Leftover sessions on a shared chromedriver
577
+
578
+ A remote chromedriver keeps a session - a whole Chrome - until someone deletes it. A warmer closes
579
+ its own sessions when they idle past `max_idle_age` and when the process shuts down cleanly, but a
580
+ process that is killed or crashes leaves its sessions open, and enough of them stop the container
581
+ from starting any new Chrome. So in remote mode each warmer records the sessions it opens in a small
582
+ registry file (`<registry_dir>/bidi2pdf-sessions-<hash of the URL>.json`, mode 0600), and on start
583
+ closes recorded sessions older than `orphan_age`. The default, twice `max_idle_age`, is beyond the
584
+ point where any live warmer would already have recycled its own spare, so a running process never
585
+ loses one. Sessions nobody recorded (other tools on the same chromedriver) are never touched.
586
+ chromedriver drops custom capabilities, so a session cannot carry a tag of its own - hence the file.
587
+
588
+ Everything here is fail-open: if the registry directory is not writable, the warmer logs one
589
+ warning, instruments `session_warmer.registry_unavailable.bidi2pdf`, and keeps rendering - only the
590
+ cleanup is off. Closed leftovers are reported as `session_warmer.orphans_closed.bidi2pdf`.
391
591
 
392
592
  > **Security note:** an idle warm session is an open, unauthenticated automation endpoint
393
593
  > (chromedriver's port, Chrome's debugging port - loopback only for a local chromedriver) for as
@@ -395,6 +595,28 @@ Bidi2pdf::SessionWarmer.shutdown
395
595
  > nothing else can reach those ports. With `remote_browser_url`, who can reach that endpoint on the
396
596
  > network is what matters, exactly as it does without the warmer.
397
597
 
598
+ ### Customizing Chrome arguments (and blank PDFs from inline HTML)
599
+
600
+ `Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS` already contains one `--disable-features=...` entry,
601
+ and Chrome only honors the **last** occurrence of that switch. To turn another feature off, extend
602
+ that entry instead of appending a second switch, which would silently drop the defaults:
603
+
604
+ ```ruby
605
+ chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.map do |arg|
606
+ arg.start_with?("--disable-features=") ? "#{arg},LocalNetworkAccessChecks" : arg
607
+ end
608
+ ```
609
+
610
+ `LocalNetworkAccessChecks` is the one you are most likely to need. HTML rendered through
611
+ `BrowserTab#render_html_content` is loaded as a `data:` URL, which Chrome treats as a public origin;
612
+ if that HTML references assets on a private address (`localhost`, a Docker hostname, ...), recent
613
+ Chrome versions (confirmed with Chrome 153) block those requests before they are sent. Nothing
614
+ raises - the assets show up as failed network events, the server never sees a request, and the PDF
615
+ comes out unstyled or blank. Disable the check only where the asset host really is private, and keep
616
+ it on when rendering pages you do not control.
617
+ The [bidi2pdf-rails README](https://github.com/dieter-medium/bidi2pdf-rails#readme) has the full
618
+ walkthrough under "Blank PDFs: Chrome's Local Network Access Check".
619
+
398
620
  ---
399
621
 
400
622
  ## 🚂 Rails Integration
data/docker/Dockerfile CHANGED
@@ -34,10 +34,21 @@ 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
45
+ # Chromium's crash handler gets its database path from the XDG config dir. On a read-only root
46
+ # (docker run --read-only) that dir cannot be created, the handler refuses to start
47
+ # ("--database is required") and Chrome aborts on every launch. /tmp is the one place such a
48
+ # container is expected to mount writable (--tmpfs /tmp).
49
+ ENV XDG_CONFIG_HOME=/tmp/xdg-config \
50
+ XDG_CACHE_HOME=/tmp/xdg-cache
51
+
41
52
  USER appuser
42
53
 
43
54
  CMD ["/usr/bin/bash"]
@@ -53,6 +53,13 @@ WORKDIR /app
53
53
  RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix
54
54
 
55
55
  # Switch to non-root user
56
+ # Chromium's crash handler gets its database path from the XDG config dir. On a read-only root
57
+ # (docker run --read-only) that dir cannot be created, the handler refuses to start
58
+ # ("--database is required") and Chrome aborts on every launch. /tmp is the one place such a
59
+ # container is expected to mount writable (--tmpfs /tmp).
60
+ ENV XDG_CONFIG_HOME=/tmp/xdg-config \
61
+ XDG_CACHE_HOME=/tmp/xdg-cache
62
+
56
63
  USER appuser
57
64
 
58
65
  # RUN gem install chromedriver-binary && ruby -e 'require "chromedriver/binary"; puts Chromedriver::Binary::ChromedriverDownloader.update'
@@ -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
@@ -69,6 +73,13 @@ WORKDIR /app
69
73
  RUN chown -R appuser:appuser /app
70
74
 
71
75
  # Switch to non-root user
76
+ # Chromium's crash handler gets its database path from the XDG config dir. On a read-only root
77
+ # (docker run --read-only) that dir cannot be created, the handler refuses to start
78
+ # ("--database is required") and Chrome aborts on every launch. /tmp is the one place such a
79
+ # container is expected to mount writable (--tmpfs /tmp).
80
+ ENV XDG_CONFIG_HOME=/tmp/xdg-config \
81
+ XDG_CACHE_HOME=/tmp/xdg-cache
82
+
72
83
  USER appuser
73
84
 
74
85
  CMD ["/usr/bin/bash"]
@@ -92,8 +92,17 @@ module Bidi2pdf
92
92
  @listeners_mutex.synchronize { @listeners[event].dup }.each { |listener| listener.call(*) }
93
93
  end
94
94
 
95
+ # The reader closes the socket from its own thread when the peer hangs up, and #close does not
96
+ # wait for a write in flight - so a write can fail with "stream closed in another thread" (or
97
+ # EPIPE) because *we* closed it. By then the connection is over and :close has been emitted;
98
+ # there is nothing left to report. Seen in the handshake write of #connect, which had no
99
+ # rescue: a server that answered and hung up at once made #connect raise although the
100
+ # handshake went through. Any other write failure still raises (#send and #say_goodbye
101
+ # handle it).
95
102
  def write(bytes)
96
103
  @write_mutex.synchronize { @socket&.write bytes }
104
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
105
+ raise unless @closed
97
106
  end
98
107
 
99
108
  def say_goodbye
@@ -74,6 +74,10 @@ module Bidi2pdf
74
74
  # @return [Array<String>] The Chrome arguments for the session.
75
75
  attr_reader :chrome_args
76
76
 
77
+ # @return [String, nil] chromedriver's id for this session, once it was created - what
78
+ # SessionWarmer records so a later process can close it if this one dies uncleanly.
79
+ attr_reader :session_id
80
+
77
81
  # Initializes a new session.
78
82
  #
79
83
  # @param [String] session_url The URL for the session.
@@ -227,7 +231,7 @@ module Bidi2pdf
227
231
  value = session_data["value"]
228
232
  handle_error(value) if value.nil? || value["error"]
229
233
 
230
- session_id = value["sessionId"]
234
+ @session_id = value["sessionId"]
231
235
  ws_url = value["capabilities"]["webSocketUrl"]
232
236
 
233
237
  Bidi2pdf.logger.info "Created session with ID: #{session_id}"
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ class CLI < Thor
5
+ # Shared machinery for --json/--json-stream/--output - across render/diagnose/run: reserving
6
+ # stdout for exactly one machine-readable payload, and mapping a Result to a process exit
7
+ # status.
8
+ module JsonOutput
9
+ private
10
+
11
+ # Bidi2pdf.logger (and friends) default to $stdout (see lib/bidi2pdf.rb), so a
12
+ # --json/--output - render has to redirect them for its duration or every log line would
13
+ # land in the same stream as the JSON document / PDF bytes it is trying to keep pure.
14
+ # Logger#reopen swaps the destination in place, so nothing else holding a reference to
15
+ # these loggers needs to know.
16
+ def reserve_stdout_for_machine_output
17
+ loggers = [Bidi2pdf.logger, Bidi2pdf.network_events_logger, Bidi2pdf.browser_console_logger].compact
18
+ loggers.each { |logger| logger.logger.reopen($stderr) }
19
+
20
+ yield
21
+ ensure
22
+ loggers.each { |logger| logger.logger.reopen($stdout) }
23
+ end
24
+
25
+ def exit_for_result(result)
26
+ exit(result.ok? ? Bidi2pdf::ExitCodes::SUCCESS : Bidi2pdf::ExitCodes.for(result.error[:code]))
27
+ end
28
+ end
29
+ end
30
+ end