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:
wss://<host>/<PLAYWRIGHT_TOKEN>. Connect a
Playwright client and interact normally.https://<host>/api/…. Capture and retrieve the
downloaded file./api/health requires the header X-API-Key: <DOWNLOAD_API_KEY>.
The websocket is protected by its secret path token.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:
POST /api/reserve before clicking (queues if busy) → gets a token;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.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 |
| 409 | another download is in progress (queue timed out) |
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>" } |
|---|---|
| 200 | the file bytes (Content-Disposition: attachment) |
| 404 | no freshly-staged artifact found in time |
| 409 | invalid 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.
Free a reservation on an error path (idempotent). Body { "token": "…" } → 200.
Re-fetch an already-captured file by exact name (no capture, no lock). 404 if absent.
List captured files: { "files": [ { "filename": "…", "size": 123 } ] }. GUID staging files are hidden.
No auth. { "status": "ok" }.
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()
/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.