Playwright Service

Drive a browser remotely with normal Playwright, download a file, and pull it back over HTTP — built for Microsoft Fabric, which can't install browsers.

Two interfaces share one HTTPS ingress:

Auth. Every /api/* route except /api/health requires the header X-API-Key: <DOWNLOAD_API_KEY>. The websocket is protected by its secret path token.

The flow

Downloads are serialized — the browser stages every download into one folder under a random GUID name, so only one capture can run at a time. A client therefore:

  1. connects and navigates/selects with normal Playwright;
  2. calls POST /api/reserve before clicking (queues if busy) → gets a token;
  3. triggers the download, then calls POST /api/download with that token — which waits for the file, renames it to its real name, streams the bytes back, and releases the lock.

API reference

POST /api/reserve X-API-Key

Take the download lock before clicking the download button. Blocks/queues up to RESERVE_WAIT_S if another download holds it.

Body (optional){ "wait": <seconds to queue, default RESERVE_WAIT_S> }
200{ "token": "…", "ttl": 300 } — pass token to /api/download
409another download is in progress (queue timed out)

POST /api/download X-API-Key

Capture + download in one call. Waits for the just-downloaded artifact, renames it to the real filename, streams the bytes, and releases the reservation. Returns exactly the named file — never an older/other one.

Body{ "filename": "<download.suggested_filename>", "since": <epoch secs, optional>, "token": "<from /reserve>" }
200the file bytes (Content-Disposition: attachment)
404no freshly-staged artifact found in time
409invalid or expired reservation token

Set since to the moment just before the click; if omitted it defaults to the last CAPTURE_SINCE_GRACE_S seconds so a stale file is never returned. token is optional — omit only if a single client uses the service at a time.

POST /api/release X-API-Key

Free a reservation on an error path (idempotent). Body { "token": "…" }200.

GET /api/download/<file> X-API-Key

Re-fetch an already-captured file by exact name (no capture, no lock). 404 if absent.

GET /api/list X-API-Key

List captured files: { "files": [ { "filename": "…", "size": 123 } ] }. GUID staging files are hidden.

GET /api/health

No auth. { "status": "ok" }.

The Playwright call

Connect a Playwright client to the websocket — no browser binaries needed locally, the browser is remote. Use chromium.connect() (not connect_over_cdp).

# pip install playwright requests   (no `playwright install` needed — browser is remote)
import time, requests
from playwright.async_api import async_playwright

HOST  = "your-app.azurecontainerapps.io"
TOKEN = "your-secret-token"          # PLAYWRIGHT_TOKEN
KEY   = "your-download-api-key"      # DOWNLOAD_API_KEY
WS    = f"wss://{HOST}/{TOKEN}"
API   = f"https://{HOST}/api"
H     = {"X-API-Key": KEY}

async def run():
    async with async_playwright() as p:
        browser = await p.chromium.connect(WS)
        page = await (await browser.new_context(accept_downloads=True)).new_page()

        await page.goto("https://example.com/...")        # navigate / select as needed

        token = requests.post(f"{API}/reserve", headers=H, json={}).json()["token"]  # queue
        try:
            since = time.time() - 2
            async with page.expect_download() as di:       # arm BEFORE the click
                await page.get_by_role("button", name="Download").click()
            name = (await di.value).suggested_filename

            # ONE call (browser still open): capture + stream the bytes + release the lock
            data = requests.post(f"{API}/download", headers=H,
                                 json={"filename": name, "since": since, "token": token}).content
        except BaseException:
            requests.post(f"{API}/release", headers=H, json={"token": token})  # free the lock
            raise

        await browser.close()
    return name, data

# In a Fabric / Jupyter cell:  name, data = await run()
Keep the browser open until /api/download returns — Playwright deletes the staged file when the context/browser closes. If expect_download() times out, the button didn't emit a download event; check DevTools → Network for the real request.

This page is served at / and /api/docs.