Module: PlaywrightRuntime
- Defined in:
- app/services/playwright_runtime.rb
Overview
Resolves how to obtain a Playwright browser for the current environment, and
yields a connected browser to callers via PlaywrightRuntime.with_browser.
Two modes, selected by the PLAYWRIGHT_SERVER_URL env var:
-
REMOTE (production / staging) — PLAYWRIGHT_SERVER_URL is set, e.g.
ws://heatwave-playwright:3000/ws. The Kamal app image ships NO Node /
Playwright / browsers (see Dockerfile + .dockerignore); browsers run in a
separateplaywrightKamal accessory (mcr.microsoft.com/playwright) and the
app connects over the kamal network viaconnect_to_browser_server. See the
playwrightaccessory in config/deploy.yml / config/deploy.staging.yml. -
LOCAL (development) — PLAYWRIGHT_SERVER_URL is unset; launch a browser with
the local driver resolved by PlaywrightRuntime.cli_path. Lookup order:
a. node_modules/.bin/playwright (whereyarn installprovides it)
b. ~/.playwright-driver/ (standalone driver, bundles its own Node)
c. npx playwright (fallback)
The standalone driver is installed with:
wget https://playwright.azureedge.net/builds/driver/playwright--linux.zip
unzip -d ~/.playwright-driver/
VERSION LOCKSTEP: in remote mode the accessory image tag MUST equal the gem's
Playwright::COMPATIBLE_PLAYWRIGHT_VERSION (the wire protocol is version-matched).
Bumping playwright-ruby-client means bumping the accessory image tag in the same PR.
Constant Summary collapse
- STANDALONE_DRIVER_DIR =
Standalone driver dir (local mode).
File.join(Dir.home, '.playwright-driver').freeze
- SESSION_TIMEOUT_SECONDS =
Hard ceiling on ANY browser session. Not an SLA — every caller's own
per-call timeouts are 30-60s and a real session finishes in seconds. This is
the backstop for the failure mode those per-call timeouts cannot see: when
playwright-ruby-client's websocket reader thread dies, nobody reads the
socket, so every pending protocol call — including the server's own timeout
REPLY — blocks forever. Gem 1.61.0 did exactly that on the Menard portal and
wedged the EDI inventory flow for three weeks
(doc/troubleshooting/MENARD_PLAYWRIGHT_OUTAGE_2026_07.md).Sized above the worst case of the slowest caller (Menard: ~8 sequential
waits of 30-60s each) so it can only ever fire on a hang, never on a slow
but progressing session. Callers wanting a tighter DOMAIN deadline pass
timeout:or wrap their own — e.g. Edi::Menard::InventoryMessageSender's
300s, which exists to produce an operator-facing ECL note. 600
Class Method Summary collapse
-
.cli_path ⇒ String
Resolves the local Playwright CLI executable for
Playwright.create(local mode). -
.find_npx ⇒ String?
Locates the
npxexecutable, if any, for the local-driver fallback. -
.remote? ⇒ Boolean
True when a remote Playwright server is configured (production / staging).
-
.server_url ⇒ String?
ws:// endpoint of the remote Playwright server accessory, or nil in local mode.
-
.with_browser(launch_options: {}, browser_type: 'chromium', timeout: SESSION_TIMEOUT_SECONDS) {|browser| ... } ⇒ Object
Yields a connected Playwright::Browser, picking remote vs local per PlaywrightRuntime.server_url, and cleans it up afterwards.
Class Method Details
.cli_path ⇒ String
Resolves the local Playwright CLI executable for Playwright.create (local mode).
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'app/services/playwright_runtime.rb', line 111 def cli_path local = Rails.root.join('node_modules/.bin/playwright') return local.to_s if File.exist?(local) standalone_node = File.join(STANDALONE_DRIVER_DIR, 'node') standalone_cli = File.join(STANDALONE_DRIVER_DIR, 'package', 'cli.js') return "#{standalone_node} #{standalone_cli}" if File.exist?(standalone_node) && File.exist?(standalone_cli) npx = find_npx return "#{npx} playwright" if npx raise "Playwright runtime not found. Set PLAYWRIGHT_SERVER_URL to use the remote " \ "Playwright accessory, install the standalone driver in #{STANDALONE_DRIVER_DIR}, " \ "or ensure node_modules/.bin/playwright exists (yarn install)." end |
.find_npx ⇒ String?
Locates the npx executable, if any, for the local-driver fallback.
130 131 132 133 |
# File 'app/services/playwright_runtime.rb', line 130 def find_npx path = `which npx 2>/dev/null`.strip path.presence end |
.remote? ⇒ Boolean
True when a remote Playwright server is configured (production / staging).
61 62 63 |
# File 'app/services/playwright_runtime.rb', line 61 def remote? server_url.present? end |
.server_url ⇒ String?
ws:// endpoint of the remote Playwright server accessory, or nil in local mode.
55 56 57 |
# File 'app/services/playwright_runtime.rb', line 55 def server_url ENV['PLAYWRIGHT_SERVER_URL'].presence end |
.with_browser(launch_options: {}, browser_type: 'chromium', timeout: SESSION_TIMEOUT_SECONDS) {|browser| ... } ⇒ Object
Yields a connected Playwright::Browser, picking remote vs local per server_url,
and cleans it up afterwards. The block's return value is passed through.
launch_options apply ONLY to a local launch — a remote browser is pre-launched
by playwright run-server on the accessory, so the client cannot set its CLI args
(e.g. --no-sandbox / --disable-dev-shm-usage). The accessory image handles the
sandbox; size /dev/shm via the accessory's options: { shm-size } instead.
Every session is capped at SESSION_TIMEOUT_SECONDS; see that constant for
why a Thread#raise-based Timeout is the only guard that works here. Unwinding
is safe: the gem's teardown closes the websocket with its own 2s bound
(WebSocketTransport#stop), so the ensure can't hang after the raise.
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'app/services/playwright_runtime.rb', line 84 def with_browser(launch_options: {}, browser_type: 'chromium', timeout: SESSION_TIMEOUT_SECONDS, &) # Timeout.timeout treats nil and 0 as "run without a timeout", so a caller # passing either would silently restore the indefinite hang this guard # exists to stop — the failure would look exactly like having no guard. raise ArgumentError, "timeout must be a positive number, got #{timeout.inspect}" \ unless timeout.is_a?(Numeric) && timeout.positive? Timeout.timeout(timeout, nil, "Playwright session exceeded #{timeout}s") do if (ws = server_url) Playwright.connect_to_browser_server(ws, browser_type: browser_type, &) else Playwright.create(playwright_cli_executable_path: cli_path) do |playwright| browser = playwright.public_send(browser_type).launch(**) begin yield(browser) ensure browser&.close end end end end end |