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. The API Key is the same as the Playwright 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).
playwright package must be
the same version as the server (image mcr.microsoft.com/playwright:v1.60.0).
A mismatch fails the connect with 428 Precondition Required.
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.run() above returns (name, data) — the real filename and the
file bytes. Write those bytes to the attached lakehouse's Files area. The
/lakehouse/default/… path is the default lakehouse mounted on the Fabric
notebook; use it directly with normal file I/O.
import os
name, data = await run() # from the example above
LAKEHOUSE_DIR = "/lakehouse/default/Files/downloads"
os.makedirs(LAKEHOUSE_DIR, exist_ok=True)
dest = os.path.join(LAKEHOUSE_DIR, name)
with open(dest, "wb") as f:
f.write(data)
print(f"Wrote {len(data)} bytes to {dest}")
# The file now shows up under Files/downloads/ in the lakehouse. To load a CSV/Excel
# straight into a Spark DataFrame instead (path uses the Files/ prefix, no /lakehouse):
# df = spark.read.option("header", True).csv(f"Files/downloads/{name}")
df.write.format("delta").saveAsTable("vessels")).This page is served at /,
/docs and /api/docs.