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
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ # Keeps a small number of Chrome sessions pre-warmed (chromedriver started, WebSocket connected,
5
+ # browser ready) so a render can skip that startup latency on the request path. Isolation matches
6
+ # today's one-Chrome-per-render model exactly: every checked-out slot is used for exactly one
7
+ # +with_tab+ block and then retired (never returned to the cache) - only a fresh replacement is
8
+ # warmed in its place, off the request path. Checkout never waits for a warm slot: one is used if
9
+ # it is ready, otherwise a slot is created synchronously on the spot, i.e. today's exact behavior
10
+ # for that one render - including its failure mode: if that cold start fails, the error propagates
11
+ # out of +with_tab+ exactly as it would without the warmer.
12
+ #
13
+ # @example Rails initializer
14
+ # Bidi2pdf::SessionWarmer.configure do |c|
15
+ # c.size = 2
16
+ # c.headless = true
17
+ # end
18
+ #
19
+ # @example Per-request usage
20
+ # Bidi2pdf::SessionWarmer.with_tab do |tab|
21
+ # tab.navigate_to(url)
22
+ # tab.print("invoice.pdf")
23
+ # end
24
+ class SessionWarmer
25
+ # Configuration for the session warmer.
26
+ class Configuration
27
+ # @return [Integer] Number of Chrome slots to keep pre-warmed.
28
+ attr_accessor :size
29
+
30
+ # @return [Boolean] Whether to run Chrome in headless mode.
31
+ attr_accessor :headless
32
+
33
+ # @return [Array<String>] Chrome launch arguments.
34
+ attr_accessor :chrome_args
35
+
36
+ # @return [String, nil] A remote chromedriver session URL (e.g. a `remote-chrome` sidecar). When
37
+ # set, a slot connects directly to it instead of spawning a local ChromedriverManager -
38
+ # mirrors Launcher#session's own local/remote branch.
39
+ attr_accessor :remote_browser_url
40
+
41
+ # @return [#call, nil] Optional factory callable that returns a slot hash - injectable for tests.
42
+ # A custom factory owns the cleanup of anything it half-built before raising: the warmer can
43
+ # only retire a slot it was actually handed.
44
+ attr_accessor :slot_factory
45
+
46
+ # @return [Numeric, nil] Seconds a warm slot may sit unused before it is retired and replaced.
47
+ # A warm slot is an open, unauthenticated automation endpoint (chromedriver's port, Chrome's
48
+ # debugging port) for as long as it idles, so that window is bounded by default. A slot older
49
+ # than this is never handed out, and a background reaper recycles idle ones within roughly
50
+ # 1.25x this value even when no render ever comes. +nil+ disables the limit.
51
+ attr_accessor :max_idle_age
52
+
53
+ DEFAULT_MAX_IDLE_AGE = 300
54
+
55
+ def initialize
56
+ @size = 1
57
+ @headless = true
58
+ @chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS
59
+ @remote_browser_url = nil
60
+ @slot_factory = nil
61
+ @max_idle_age = DEFAULT_MAX_IDLE_AGE
62
+ end
63
+
64
+ # @raise [ArgumentError] if a setting can't be honored - checked once, at construction.
65
+ def validate!
66
+ validate_size!
67
+ validate_max_idle_age!
68
+ end
69
+
70
+ private
71
+
72
+ def validate_size!
73
+ return if size.is_a?(Integer) && !size.negative?
74
+
75
+ raise ArgumentError, "size must be a non-negative Integer, got #{size.inspect}"
76
+ end
77
+
78
+ def validate_max_idle_age!
79
+ return if max_idle_age.nil? || (max_idle_age.is_a?(Numeric) && max_idle_age.positive?)
80
+
81
+ raise ArgumentError, "max_idle_age must be nil or a positive number of seconds, got #{max_idle_age.inspect}"
82
+ end
83
+ end
84
+
85
+ class << self
86
+ # Configures the warmer and eagerly (re)creates the singleton, warming config.size slots right
87
+ # here - at boot/configuration time, off the request path - instead of lazily on whichever
88
+ # request happens to trigger the first #with_tab (which would otherwise pay for every
89
+ # configured slot, serially, on that one unlucky request).
90
+ def configure
91
+ @config = Configuration.new
92
+ yield @config if block_given?
93
+ @instance&.shutdown
94
+ # Cleared first: if the constructor below raises, a stale, already-shut-down instance must
95
+ # not stay registered (it would keep serving cold slots but never warm again).
96
+ @instance = nil
97
+ @instance = new(@config, slot_factory: @config.slot_factory)
98
+ end
99
+
100
+ # Returns the warmer's configuration, initializing defaults if needed.
101
+ def config
102
+ @config ||= Configuration.new
103
+ end
104
+
105
+ # Returns a callable that creates one real, fully-warmed Chrome slot from +config+. If building
106
+ # fails part-way (chromedriver up, but the session or its browser never came ready), whatever
107
+ # already exists is retired before the error propagates - no caller ever gets a reference to a
108
+ # half-built slot, so nobody else could clean it up.
109
+ def default_slot_factory(config)
110
+ lambda do
111
+ parts = {}
112
+ build_slot(config, parts)
113
+ rescue StandardError
114
+ retire_slot(session: parts[:session], manager: parts[:manager])
115
+ raise
116
+ end
117
+ end
118
+
119
+ # Closes a slot's session and stops its chromedriver (nil for a remote slot, or for a part that
120
+ # was never created). Each step is independent: a failure in one is logged, never raised, and
121
+ # never skips the other.
122
+ def retire_slot(session:, manager:)
123
+ safe_close("session") { session&.close }
124
+ safe_close("manager") { manager&.stop }
125
+ end
126
+
127
+ def safe_close(label)
128
+ yield
129
+ rescue StandardError => e
130
+ Bidi2pdf.logger.warn "session_warmer: error closing #{label}: #{e.message}"
131
+ end
132
+
133
+ # Returns the shared singleton warmer instance, creating it (and pre-warming it) on first call.
134
+ def instance
135
+ @instance ||= new(config, slot_factory: config.slot_factory)
136
+ end
137
+
138
+ # Checks out a slot, yields a fresh tab for one render, then retires the slot.
139
+ def with_tab(&)
140
+ instance.with_tab(&)
141
+ end
142
+
143
+ # Retires every currently-warm spare and resets the singleton.
144
+ def shutdown
145
+ @instance&.shutdown
146
+ @instance = nil
147
+ end
148
+
149
+ private
150
+
151
+ # Records each part in +parts+ the moment it exists, so #default_slot_factory's rescue can
152
+ # retire exactly what was created so far. Mirrors Launcher#session's local/remote branch.
153
+ def build_slot(config, parts)
154
+ config.remote_browser_url ? connect_remote(config, parts) : start_local(config, parts)
155
+
156
+ { session: parts[:session], browser: parts[:session].browser, manager: parts[:manager] }
157
+ end
158
+
159
+ def connect_remote(config, parts)
160
+ parts[:session] = Bidi2pdf::Bidi::Session.new(
161
+ session_url: config.remote_browser_url,
162
+ headless: config.headless,
163
+ chrome_args: config.chrome_args
164
+ )
165
+ end
166
+
167
+ def start_local(config, parts)
168
+ parts[:manager] = Bidi2pdf::ChromedriverManager.new(port: 0, headless: config.headless, chrome_args: config.chrome_args)
169
+ parts[:manager].start
170
+ parts[:session] = parts[:manager].session
171
+ end
172
+ end
173
+
174
+ def initialize(config, slot_factory: nil)
175
+ config.validate!
176
+ @config = config
177
+ @slot_factory = slot_factory || self.class.default_slot_factory(config)
178
+ @mutex = Mutex.new
179
+ @available = []
180
+ @replenish_threads = []
181
+ @warming = 0
182
+ @shutdown = false
183
+ @reaper_wakeup = Thread::Queue.new
184
+ prewarm
185
+ @reaper = Thread.new { reap_loop } if @config.max_idle_age
186
+ end
187
+
188
+ # Checks out a slot, creates an isolated UserContext/Window/Tab for one render, yields the tab,
189
+ # then unconditionally closes those resources and retires the underlying slot.
190
+ def with_tab
191
+ slot = checkout
192
+ user_context = nil
193
+ window = nil
194
+ tab = nil
195
+
196
+ begin
197
+ user_context = slot[:browser].create_user_context
198
+ window = user_context.create_browser_window
199
+ tab = window.create_browser_tab
200
+ yield tab
201
+ ensure
202
+ safe_close("tab") { tab&.close }
203
+ safe_close("window") { window&.close }
204
+ safe_close("user context") { user_context&.close }
205
+ retire(slot)
206
+ end
207
+ end
208
+
209
+ # Retires every currently-warm spare and waits for any in-flight background replenishment to
210
+ # finish (each of those, seeing @shutdown, retires its own result instead of stashing it - see
211
+ # #stash_or_retire). A subsequent #with_tab still works - it just falls back to a synchronous
212
+ # slot, since checkout never depends on a warm one being there.
213
+ def shutdown
214
+ spares, threads = @mutex.synchronize do
215
+ @shutdown = true
216
+ [@available.dup.tap { @available.clear }, @replenish_threads.dup.tap { @replenish_threads.clear }]
217
+ end
218
+
219
+ @reaper_wakeup << :stop
220
+ @reaper&.join
221
+ threads.each(&:join)
222
+ spares.each { |slot| retire(slot) }
223
+ end
224
+
225
+ private
226
+
227
+ # Fail-fast on purpose (a Chrome that can't start at boot should be loud), but not leaky: if slot
228
+ # N fails, no instance is returned to own slots 1..N-1, so they are retired here first.
229
+ def prewarm
230
+ @config.size.times { @available << stamp(create_slot) }
231
+ rescue StandardError
232
+ @available.each { |slot| retire(slot) }
233
+ @available.clear
234
+ raise
235
+ end
236
+
237
+ def create_slot
238
+ @slot_factory.call
239
+ end
240
+
241
+ # Session#started? is just an internal flag set once at startup - it never flips back if Chrome,
242
+ # ChromeDriver, or the WebSocket dies externally while a slot sits idle in the cache. The
243
+ # client's own #open? is kept live by the reader thread noticing a real socket error, so
244
+ # checking it too catches that case - cheap (no network round trip), though still a heuristic,
245
+ # not a full liveness guarantee (a stuck-but-not-yet-disconnected socket still reads healthy).
246
+ def healthy?(slot)
247
+ slot[:session].started? && slot[:session].client&.open? == true
248
+ end
249
+
250
+ def now
251
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
252
+ end
253
+
254
+ # Marks the moment a slot entered the cache - idle age counts from here, not from when its
255
+ # Chrome started, since it is the unattended waiting that max_idle_age bounds.
256
+ def stamp(slot)
257
+ slot.merge(warmed_at: now)
258
+ end
259
+
260
+ def expired?(slot)
261
+ !@config.max_idle_age.nil? && now - slot[:warmed_at] > @config.max_idle_age
262
+ end
263
+
264
+ # Checkout alone can't bound idle time: with no traffic nothing would ever look at a spare. This
265
+ # thread does, four times per max_idle_age, so an unused slot is recycled within ~1.25x of it.
266
+ # Thread::Queue#pop(timeout:) doubles as an interruptible sleep - #shutdown pushes :stop.
267
+ def reap_loop
268
+ interval = [@config.max_idle_age / 4.0, 0.05].max
269
+ recycle_expired until @reaper_wakeup.pop(timeout: interval) == :stop
270
+ end
271
+
272
+ def recycle_expired
273
+ expired = @mutex.synchronize do
274
+ next [] if @shutdown
275
+
276
+ old, fresh = @available.partition { |slot| expired?(slot) }
277
+ @available.replace(fresh)
278
+ old
279
+ end
280
+ return if expired.empty?
281
+
282
+ Bidi2pdf.notification_service.instrument("session_warmer.expired.bidi2pdf", { count: expired.size })
283
+ replenish_async
284
+ expired.each { |slot| retire(slot) }
285
+ rescue StandardError => e
286
+ Bidi2pdf.logger.warn "session_warmer: recycling idle slots failed: #{e.message}"
287
+ end
288
+
289
+ # A warm hit takes the spare and triggers a background replacement; a miss (empty cache, or a
290
+ # spare that died while idle) falls straight through to a synchronous slot - it never waits, so
291
+ # an under-provisioned warmer is never worse than not having one. A cold start that fails raises,
292
+ # as it would without the warmer.
293
+ #
294
+ # Oldest spare first (shift, not pop), so no slot lingers at the bottom of the cache while newer
295
+ # ones are used; and one past max_idle_age is never handed out, even if the reaper hasn't got to
296
+ # it yet - it is retired like a dead spare and this render cold-starts instead.
297
+ def checkout
298
+ slot = @mutex.synchronize { @available.shift }
299
+ had_spare = !slot.nil?
300
+ hit = had_spare && !expired?(slot) && healthy?(slot)
301
+ taken = nil
302
+
303
+ Bidi2pdf.notification_service.instrument("session_warmer.checkout.bidi2pdf", { hit: hit }) do
304
+ taken = hit ? slot : cold_checkout(slot)
305
+ end
306
+
307
+ # Every checkout tops the cache back up, hit or miss - #replenish_async itself bounds the work
308
+ # to the current deficit, so a miss on an already-full-or-filling cache starts nothing.
309
+ replenish_async
310
+ taken
311
+ end
312
+
313
+ def cold_checkout(dead_slot)
314
+ retire(dead_slot) if dead_slot
315
+ create_slot
316
+ end
317
+
318
+ # Tops the cache up towards config.size, counting warmers already in flight. Deficit-based on
319
+ # purpose, not "replace what this checkout popped": that rule could never recover from a single
320
+ # failed warm (nothing stashed -> every later checkout a miss -> never replenished again), while
321
+ # replenishing unconditionally let @available grow without bound under a burst of misses.
322
+ # available + warming never exceeds config.size, and a failed warm frees its reservation, so the
323
+ # next checkout simply tries again.
324
+ #
325
+ # Threads are created and registered inside the same critical section, so #shutdown's snapshot
326
+ # can't miss one that has started but isn't listed yet (#warm_one needs this mutex to finish,
327
+ # so it just waits for it).
328
+ def replenish_async
329
+ @mutex.synchronize do
330
+ next if @shutdown
331
+
332
+ @replenish_threads.select!(&:alive?)
333
+ deficit = @config.size - (@available.size + @warming)
334
+ deficit.times do
335
+ @warming += 1
336
+ @replenish_threads << Thread.new { warm_one }
337
+ end
338
+ end
339
+ end
340
+
341
+ def warm_one
342
+ slot = create_slot
343
+ rescue StandardError => e
344
+ @mutex.synchronize { @warming -= 1 }
345
+ Bidi2pdf.logger.warn "session_warmer: failed to warm a replacement slot: #{e.message}"
346
+ Bidi2pdf.notification_service.instrument("session_warmer.warm_failed.bidi2pdf", { error: e.class.name })
347
+ else
348
+ stash_or_retire(slot)
349
+ end
350
+
351
+ # A replacement warmed after #shutdown has nothing to stash into - retire it immediately rather
352
+ # than leaking a live Chrome process that nothing will ever check out. Either way this warmer's
353
+ # reservation is released here, in the same critical section as the stash.
354
+ def stash_or_retire(slot)
355
+ discard = @mutex.synchronize do
356
+ @warming -= 1
357
+
358
+ if @shutdown
359
+ true
360
+ else
361
+ @available << stamp(slot)
362
+ false
363
+ end
364
+ end
365
+
366
+ retire(slot) if discard
367
+ end
368
+
369
+ def retire(slot)
370
+ self.class.retire_slot(session: slot[:session], manager: slot[:manager])
371
+ end
372
+
373
+ def safe_close(label, &)
374
+ self.class.safe_close(label, &)
375
+ end
376
+ end
377
+ end
@@ -70,7 +70,8 @@ RSpec.configure do |config|
70
70
  config.chromedriver_container = start_chromedriver_container(
71
71
  build_dir: File.join(Bidi2pdf::TestHelpers.configuration.docker_dir, ".."),
72
72
  mounts: config.respond_to?(:chromedriver_mounts) ? config.chromedriver_mounts : {},
73
- shared_network: config.shared_network
73
+ shared_network: config.shared_network,
74
+ chromedriver_log_level: config.respond_to?(:chromedriver_log_level) ? config.chromedriver_log_level : "WARNING"
74
75
  )
75
76
 
76
77
  reporter.message("🚀 chromedriver container started for tests")
@@ -121,12 +122,13 @@ end
121
122
  # alias the long class name
122
123
  ChromedriverTestcontainer = Bidi2pdf::TestHelpers::Testcontainers::ChromedriverContainer
123
124
 
124
- def start_chromedriver_container(build_dir:, mounts:, shared_network:)
125
+ def start_chromedriver_container(build_dir:, mounts:, shared_network:, chromedriver_log_level: "WARNING")
125
126
  container = ChromedriverTestcontainer.new(ChromedriverTestcontainer::DEFAULT_IMAGE,
126
127
  build_dir: build_dir,
127
128
  docker_file: "docker/Dockerfile.chromedriver")
128
129
  .with_network(shared_network)
129
130
  .with_network_aliases("remote-chrome")
131
+ .with_env("CHROMEDRIVER_LOG_LEVEL", chromedriver_log_level)
130
132
 
131
133
  container.with_filesystem_binds(mounts) if mounts&.any?
132
134
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bidi2pdf
4
- VERSION = "0.1.14"
4
+ VERSION = "0.1.16"
5
5
  end
data/lib/bidi2pdf.rb CHANGED
@@ -9,9 +9,11 @@ require_relative "bidi2pdf/bidi/session"
9
9
  require_relative "bidi2pdf/dsl"
10
10
  require_relative "bidi2pdf/notifications"
11
11
  require_relative "bidi2pdf/notifications/logging_subscriber"
12
+ require_relative "bidi2pdf/notifications/json_subscriber"
13
+ require_relative "bidi2pdf/session_warmer"
12
14
  require_relative "bidi2pdf/verbose_logger"
13
15
 
14
- module Bidi2pdf
16
+ module Bidi2pdf # rubocop:disable Metrics/ModuleLength
15
17
  PAPER_FORMATS_CM = {
16
18
  letter: { width: 21.59, height: 27.94 },
17
19
  legal: { width: 21.59, height: 35.56 },
@@ -26,11 +28,32 @@ module Bidi2pdf
26
28
  a6: { width: 10.5, height: 14.8 }
27
29
  }.freeze
28
30
 
29
- class Error < StandardError; end
31
+ # Base class for every error this gem raises. Carries the machine-readable surface used by
32
+ # --json/--json-stream/manifests/recipe results (see Bidi2pdf::ErrorCodes): #retryable? and
33
+ # #hint are overridden per subclass below where a useful default exists; #details is set at
34
+ # raise time for structured, serializable context (e.g. which selector, which assertion).
35
+ class Error < StandardError
36
+ attr_reader :details
30
37
 
31
- class SessionNotStartedError < Error; end
38
+ def initialize(message = nil, details: {})
39
+ @details = details
40
+ super(message)
41
+ end
42
+
43
+ def retryable? = false
44
+
45
+ def hint = nil
46
+ end
47
+
48
+ class SessionNotStartedError < Error
49
+ def retryable? = true
32
50
 
33
- class WebsocketError < Error; end
51
+ def hint = "Check Chrome/chromedriver are installed and reachable, or that --remote-browser-url points at a running instance"
52
+ end
53
+
54
+ class WebsocketError < Error
55
+ def retryable? = true
56
+ end
34
57
 
35
58
  class ClientError < WebsocketError; end
36
59
 
@@ -47,7 +70,11 @@ module Bidi2pdf
47
70
 
48
71
  class CmdResponseNotStoredError < ClientError; end
49
72
 
50
- class CmdTimeoutError < ClientError; end
73
+ class CmdTimeoutError < ClientError
74
+ def retryable? = true
75
+
76
+ def hint = "Raise --default-timeout"
77
+ end
51
78
 
52
79
  class PrintError < Error; end
53
80
 
@@ -76,18 +103,74 @@ module Bidi2pdf
76
103
  @url = url
77
104
  super("Navigation to #{url} failed due to authentication error. #{message}")
78
105
  end
106
+
107
+ def hint = "Pass --auth user:pass, or check the credentials are still valid"
79
108
  end
80
109
 
81
- class NavigationTimeoutError < NavigationError; end
110
+ class NavigationTimeoutError < NavigationError
111
+ def retryable? = true
112
+
113
+ def hint = "Raise --default-timeout, or add --wait-network-idle if the page loads data after the load event"
114
+ end
115
+
116
+ class NavigationNotFoundError < NavigationError
117
+ def hint = "Check the URL is correct"
118
+ end
82
119
 
83
- class NavigationNotFoundError < NavigationError; end
120
+ class NavigationDNSError < NavigationError
121
+ attr_reader :url
122
+
123
+ def initialize(url = nil, message = nil)
124
+ @url = url
125
+ super(url ? "Navigation to #{url} failed: DNS resolution error. #{message}" : message)
126
+ end
84
127
 
85
- class NavigationDNSError < NavigationError; end
128
+ def retryable? = true
129
+
130
+ def hint = "Check the hostname is correct and reachable from this machine/container"
131
+ end
132
+
133
+ # --- Errors introduced for the CLI/recipe surface.
134
+ # Same style as the errors above: a thin subclass per failure mode, message text carried at the
135
+ # raise site so it stays exactly as specific as the situation warrants.
136
+
137
+ class MissingInputError < Error; end
138
+
139
+ class MultipleInputSourcesError < Error; end
140
+
141
+ class EmptyInputError < Error; end
142
+
143
+ class InvalidConfigError < Error; end
144
+
145
+ class InvalidPrintOptionError < Error; end
146
+
147
+ class InvalidRecipeError < Error; end
148
+
149
+ class SelectorNotFoundError < Error; end
150
+
151
+ class PageNotAsExpectedError < Error; end
152
+
153
+ class OutputWriteError < Error; end
154
+
155
+ class PdfInspectionUnavailableError < Error
156
+ def hint = "Install the pdf-reader gem, or drop assertions/fields that need it"
157
+ end
158
+
159
+ # Loaded here, after the error classes above, since these reference them at load time.
160
+ require_relative "bidi2pdf/error_codes"
161
+ require_relative "bidi2pdf/exit_codes"
162
+ require_relative "bidi2pdf/result"
163
+ require_relative "bidi2pdf/pdf_inspection"
164
+ require_relative "bidi2pdf/result_collector"
165
+ require_relative "bidi2pdf/manifest"
166
+ require_relative "bidi2pdf/schema"
167
+ require_relative "bidi2pdf/diagnose"
168
+ require_relative "bidi2pdf/recipe"
86
169
 
87
170
  # Global configuration for Bidi2pdf
88
171
 
89
172
  class << self
90
- attr_accessor :default_timeout, :enable_default_logging_subscriber
173
+ attr_accessor :default_timeout, :enable_default_logging_subscriber, :log_truncate_limit, :chromedriver_log_level
91
174
  attr_reader :logging_subscriber, :logger, :network_events_logger, :browser_console_logger, :notification_service
92
175
 
93
176
  # Allow configuration through a block
@@ -110,6 +193,26 @@ module Bidi2pdf
110
193
  end
111
194
  end
112
195
 
196
+ # Truncates a value for safe log output - a raw url/param can be a `data:` URL whose base64
197
+ # payload is proportional to document size, and logging it whole can be large enough to choke
198
+ # CI log ingestion (confirmed live: GitHub Actions' log UI stalls badly on very long single
199
+ # lines, reading as a hung job even though the process underneath is fine).
200
+ #
201
+ # @param [Object] value The value to truncate (converted via #to_s).
202
+ # @param [Integer] limit The maximum number of bytes to keep. Defaults to
203
+ # +Bidi2pdf.log_truncate_limit+, itself configurable via +Bidi2pdf.configure+.
204
+ # @return [String] The value unchanged if short enough, otherwise a truncated prefix plus a
205
+ # byte-count marker. Truncation is byte-based (not character-based), since the goal is
206
+ # bounding actual log-entry size; a partial trailing multi-byte character is scrubbed rather
207
+ # than left as invalid UTF-8.
208
+ def truncate_for_log(value, limit: log_truncate_limit)
209
+ str = value.to_s
210
+ return str if str.bytesize <= limit
211
+
212
+ truncated = str.byteslice(0, limit).scrub("")
213
+ "#{truncated}... (#{str.bytesize} bytes total)"
214
+ end
215
+
113
216
  def translate_paper_format(format)
114
217
  format = format.to_s.downcase.to_sym
115
218
 
@@ -161,6 +264,8 @@ module Bidi2pdf
161
264
 
162
265
  config.default_timeout = 60
163
266
 
267
+ config.log_truncate_limit = 200
268
+
164
269
  config.notification_service = Notifications
165
270
  end
166
271
  end
@@ -235,6 +235,23 @@ module Bidi2pdf
235
235
 
236
236
  def navigate_with_listeners: (untyped url, ?wait: ::String) -> untyped
237
237
 
238
+ def raise_navigation_error_for: (untyped url, untyped error) -> untyped
239
+
240
+ # browsingContext.navigate's own response never carries an HTTP status - only a
241
+ # network.responseCompleted event does, correlated back to this specific navigation via its
242
+ # "navigation" field (a Chrome/redirect-chain-wide ID, not the network request's own id).
243
+ # register_event_listeners already ran above, so network_events has been tracking since
244
+ # before the navigate command was even sent. Deliberately conservative: with no navigation
245
+ # ID, or no correlated *completed* request found, this stays silent rather than guessing -
246
+ # only a positively confirmed >= 400 status raises. max_by(&:start_timestamp) picks the last
247
+ # hop of a redirect chain (every hop shares the same navigation ID), matching the page the
248
+ # browser actually ended up on.
249
+ def check_navigation_http_status: (untyped url, untyped navigation_id) -> (nil | untyped)
250
+
251
+ # The last hop of a redirect chain (every hop shares the same navigation ID) - the page the
252
+ # browser actually ended up on, not wherever the chain started.
253
+ def correlated_navigation_response: (untyped navigation_id) -> untyped
254
+
238
255
  def register_event_listeners: () -> (nil | untyped)
239
256
 
240
257
  def handle_injection_exception: (untyped response, untyped url, untyped exception_class) -> untyped
@@ -0,0 +1,81 @@
1
+ module Bidi2pdf
2
+ module Bidi
3
+ # A threaded WebSocket client on the `websocket` gem's framing - the same shape Selenium's Ruby
4
+ # BiDi client uses. It replaces websocket-client-simple, whose reader pulled one byte at a time
5
+ # (`getc`, then a frame-parse attempt per byte): a printed PDF comes back as one base64 message
6
+ # and every network event for a `data:` navigation echoes the whole URL, so that loop ran
7
+ # millions of times per render - ~730 ms to decode a 700 KB frame, against ~0.5 ms in 16 KB
8
+ # chunks.
9
+ #
10
+ # Emits :open, :message (a WebSocket frame, payload in #data), :error and :close.
11
+ class BufferedWebSocketClient
12
+ @listeners: untyped
13
+
14
+ @listeners_mutex: untyped
15
+
16
+ @write_mutex: untyped
17
+
18
+ # Re-entrant: a failed write inside #close closes again.
19
+ @close_monitor: untyped
20
+
21
+ @handshaked: untyped
22
+
23
+ @closed: untyped
24
+
25
+ @url: untyped
26
+
27
+ @socket: untyped
28
+
29
+ @handshake: untyped
30
+
31
+ @thread: untyped
32
+
33
+ READ_CHUNK_BYTES: 16384
34
+
35
+ attr_reader url: untyped
36
+
37
+ def self.connect: (untyped url, ?::Hash[untyped, untyped] options) ?{ (untyped) -> untyped } -> untyped
38
+
39
+ def initialize: () -> void
40
+
41
+ def on: (untyped event) { (?) -> untyped } -> untyped
42
+
43
+ def connect: (untyped url, ?::Hash[untyped, untyped] options) -> (nil | untyped)
44
+
45
+ # Commands are sent from whichever thread issues them. On a plain TCP socket one IO#write is
46
+ # already atomic, but OpenSSL::SSL::SSLSocket#write is not, so writes share a lock for wss://.
47
+ def send: (untyped data, ?type: ::Symbol) -> untyped
48
+
49
+ # Safe from any thread, including the reader's own: the reader sees the peer hang up and
50
+ # closes from inside its loop, so it must neither race a caller closing at the same moment
51
+ # nor be killed before :close has been emitted.
52
+ def close: (?untyped? error) -> untyped
53
+
54
+ def open?: () -> untyped
55
+
56
+ def closed?: () -> untyped
57
+
58
+ private
59
+
60
+ def emit: (untyped event, *untyped) -> untyped
61
+
62
+ def write: (untyped bytes) -> untyped
63
+
64
+ def say_goodbye: () -> untyped
65
+
66
+ def open_socket: (untyped uri, untyped options) -> untyped
67
+
68
+ def ssl_context: (untyped options) -> untyped
69
+
70
+ def read_loop: (untyped socket, untyped frame) -> untyped
71
+
72
+ def consume: (untyped chunk, untyped frame) -> (nil | untyped)
73
+
74
+ # WebSocket control frames (ping/pong/close) must never reach a JSON-parsing consumer -
75
+ # WebSocketDispatcher tries to parse every :message payload as JSON, so a raw close frame
76
+ # would raise JSON::ParserError, and an unanswered ping can make chromedriver or an
77
+ # intermediate proxy tear down an otherwise healthy connection on its own keepalive timeout.
78
+ def dispatch_frame: (untyped message) -> untyped
79
+ end
80
+ end
81
+ end