render-lab-tasks-browserbase 0.1.0__tar.gz

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 (24) hide show
  1. render_lab_tasks_browserbase-0.1.0/.gitignore +11 -0
  2. render_lab_tasks_browserbase-0.1.0/LICENSE +21 -0
  3. render_lab_tasks_browserbase-0.1.0/PKG-INFO +44 -0
  4. render_lab_tasks_browserbase-0.1.0/README.md +30 -0
  5. render_lab_tasks_browserbase-0.1.0/pyproject.toml +22 -0
  6. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/__init__.py +1 -0
  7. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/_app.py +3 -0
  8. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/capture_screenshot.py +20 -0
  9. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/client.py +509 -0
  10. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/close_session.py +18 -0
  11. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/create_context.py +18 -0
  12. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/create_session.py +18 -0
  13. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/delete_context.py +18 -0
  14. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/get_download.py +18 -0
  15. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/get_session.py +18 -0
  16. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/get_session_logs.py +18 -0
  17. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/get_session_replay.py +20 -0
  18. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/list_downloads.py +18 -0
  19. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/py.typed +0 -0
  20. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/retry.py +5 -0
  21. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/run_actions.py +18 -0
  22. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/tasks.py +41 -0
  23. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/types.py +288 -0
  24. render_lab_tasks_browserbase-0.1.0/src/render_lab_tasks_browserbase/upload_files.py +18 -0
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ *.egg-info/
9
+ .env
10
+ .env.*
11
+ !.env.example
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Render Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.5
2
+ Name: render-lab-tasks-browserbase
3
+ Version: 0.1.0
4
+ Summary: browserbase tasks for Render Workflows
5
+ Project-URL: Repository, https://github.com/render-lab/render-tasks-python
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: httpx<0.29,>=0.28
10
+ Requires-Dist: playwright<2,>=1.50
11
+ Requires-Dist: render-lab-tasks-core<0.2,>=0.1.1
12
+ Requires-Dist: render==1.0.1
13
+ Description-Content-Type: text/markdown
14
+
15
+ # render-lab-tasks-browserbase
16
+
17
+ Twelve Browserbase tasks for contexts, sessions, browser actions and artifacts.
18
+ Use `render_lab_tasks_browserbase.tasks` for the wrapped tasks and injectable raw
19
+ implementations. The package root is inert; all boundaries use JSON DTOs.
20
+
21
+ ## Environment
22
+
23
+ `BROWSERBASE_API_KEY` is required lazily on first use. `BROWSERBASE_PROJECT_ID` is
24
+ an optional default project; create calls can supply `projectId` explicitly.
25
+
26
+ ## Behavior
27
+
28
+ Session creation finds a matching idempotency key before creating a paid session.
29
+ Browser actions reconnect through Playwright CDP and always close their local
30
+ connection. No local browser installation is required. Closing a session is an
31
+ explicit operation. Each HTTP operation makes one attempt; durable retries own retry timing.
32
+
33
+ Screenshots, uploads, downloads and log output enforce byte budgets. Downloads
34
+ read ZIP metadata without extracting files onto disk. The compressed archive has
35
+ a 100 MiB transfer cap, and file content is read only up to its requested bound.
36
+ Python applies the declared action timeout and keeps the total log text within its
37
+ budget; these correct omissions in the pinned TS implementation. See ADR-0014.
38
+ Live verification is pending credentials and a disposable Browserbase project.
39
+
40
+ ## Installation
41
+
42
+ ```sh
43
+ pip install render-lab-tasks-browserbase==0.1.0
44
+ ```
@@ -0,0 +1,30 @@
1
+ # render-lab-tasks-browserbase
2
+
3
+ Twelve Browserbase tasks for contexts, sessions, browser actions and artifacts.
4
+ Use `render_lab_tasks_browserbase.tasks` for the wrapped tasks and injectable raw
5
+ implementations. The package root is inert; all boundaries use JSON DTOs.
6
+
7
+ ## Environment
8
+
9
+ `BROWSERBASE_API_KEY` is required lazily on first use. `BROWSERBASE_PROJECT_ID` is
10
+ an optional default project; create calls can supply `projectId` explicitly.
11
+
12
+ ## Behavior
13
+
14
+ Session creation finds a matching idempotency key before creating a paid session.
15
+ Browser actions reconnect through Playwright CDP and always close their local
16
+ connection. No local browser installation is required. Closing a session is an
17
+ explicit operation. Each HTTP operation makes one attempt; durable retries own retry timing.
18
+
19
+ Screenshots, uploads, downloads and log output enforce byte budgets. Downloads
20
+ read ZIP metadata without extracting files onto disk. The compressed archive has
21
+ a 100 MiB transfer cap, and file content is read only up to its requested bound.
22
+ Python applies the declared action timeout and keeps the total log text within its
23
+ budget; these correct omissions in the pinned TS implementation. See ADR-0014.
24
+ Live verification is pending credentials and a disposable Browserbase project.
25
+
26
+ ## Installation
27
+
28
+ ```sh
29
+ pip install render-lab-tasks-browserbase==0.1.0
30
+ ```
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "render-lab-tasks-browserbase"
7
+ version = "0.1.0"
8
+ description = "browserbase tasks for Render Workflows"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ dependencies = ["playwright>=1.50,<2","render==1.0.1", "httpx>=0.28,<0.29", "render-lab-tasks-core>=0.1.1,<0.2"]
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/render-lab/render-tasks-python"
17
+
18
+ [tool.uv.sources]
19
+ render-lab-tasks-core = { workspace = true }
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/render_lab_tasks_browserbase"]
@@ -0,0 +1 @@
1
+ """Import .tasks explicitly to register durable tasks."""
@@ -0,0 +1,3 @@
1
+ from render import Workflows
2
+
3
+ app = Workflows()
@@ -0,0 +1,20 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_NO_RETRY
6
+ from .types import CaptureScreenshotInput, CaptureScreenshotResult
7
+
8
+
9
+ async def capture_screenshot_impl(
10
+ ctx: TaskContext, input: CaptureScreenshotInput, *, deps: Deps | None = None
11
+ ) -> CaptureScreenshotResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.capture_screenshot(input)
14
+
15
+
16
+ @app.task(name="browserbase.captureScreenshot", retry=BROWSERBASE_NO_RETRY)
17
+ async def capture_screenshot(
18
+ ctx: TaskContext, input: CaptureScreenshotInput
19
+ ) -> CaptureScreenshotResult:
20
+ return await capture_screenshot_impl(ctx, input)
@@ -0,0 +1,509 @@
1
+ """Injected vendor interface and one-attempt async HTTP adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import io
8
+ import json
9
+ import os
10
+ import zipfile
11
+ from collections.abc import AsyncIterator, Mapping
12
+ from contextlib import asynccontextmanager
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime
15
+ from typing import Any, Protocol
16
+
17
+ import httpx
18
+ from render_lab_tasks_core.bounded import (
19
+ MAX_BINARY_BYTES,
20
+ MAX_TEXT_BYTES,
21
+ BoundedText,
22
+ bounded_binary,
23
+ bounded_text,
24
+ validate_byte_limit,
25
+ )
26
+ from render_lab_tasks_core.http import ApiError, HttpClient, enc, pick, query
27
+
28
+ from . import types as t
29
+
30
+ IDEMPOTENCY_METADATA_KEY = "_render_tasks_idempotency_key"
31
+
32
+
33
+ def integer(value: float, minimum: int, maximum: float = float("inf")) -> bool:
34
+ return not isinstance(value, bool) and minimum <= value <= maximum and int(value) == value
35
+
36
+
37
+ def iso(value: Any) -> str:
38
+ if value is None:
39
+ return ""
40
+ if isinstance(value, (int, float)):
41
+ return (
42
+ datetime.fromtimestamp(value / 1000, UTC)
43
+ .isoformat(timespec="milliseconds")
44
+ .replace("+00:00", "Z")
45
+ )
46
+ return str(value)
47
+
48
+
49
+ def session(raw: Any) -> t.SessionDTO:
50
+ return {
51
+ "sessionId": raw["id"],
52
+ "projectId": raw["projectId"],
53
+ "status": raw["status"],
54
+ "contextId": raw.get("contextId"),
55
+ "region": raw["region"],
56
+ "keepAlive": raw["keepAlive"],
57
+ "createdAt": iso(raw.get("createdAt")),
58
+ "updatedAt": iso(raw.get("updatedAt")),
59
+ "startedAt": iso(raw.get("startedAt")),
60
+ "expiresAt": iso(raw.get("expiresAt")),
61
+ }
62
+
63
+
64
+ def download(session_id: str, info: zipfile.ZipInfo) -> t.DownloadDTO:
65
+ value = json.dumps(
66
+ {"sessionId": session_id, "name": info.filename}, ensure_ascii=False, separators=(",", ":")
67
+ ).encode()
68
+ extensions = {
69
+ "txt": "text/plain",
70
+ "csv": "text/csv",
71
+ "json": "application/json",
72
+ "html": "text/html",
73
+ "pdf": "application/pdf",
74
+ "png": "image/png",
75
+ "jpg": "image/jpeg",
76
+ "jpeg": "image/jpeg",
77
+ "gif": "image/gif",
78
+ "zip": "application/zip",
79
+ }
80
+ return {
81
+ "downloadId": base64.urlsafe_b64encode(value).decode().rstrip("="),
82
+ "sessionId": session_id,
83
+ "filename": info.filename,
84
+ "contentType": extensions.get(
85
+ info.filename.rsplit(".", 1)[-1].lower(), "application/octet-stream"
86
+ ),
87
+ "byteCount": info.file_size,
88
+ "checksum": f"{info.CRC:08x}",
89
+ "createdAt": "",
90
+ }
91
+
92
+
93
+ def all_match(item: Any, input: Any, keys: tuple[str, ...]) -> bool:
94
+ return all(key not in input or item[key] == input[key] for key in keys)
95
+
96
+
97
+ def serialize(value: Any, fallback: str) -> str:
98
+ return (
99
+ json.dumps(value, ensure_ascii=False, separators=(",", ":"))
100
+ if value is not None
101
+ else fallback
102
+ )
103
+
104
+
105
+ class Port(Protocol):
106
+ async def capture_screenshot(
107
+ self, input: t.CaptureScreenshotInput
108
+ ) -> t.CaptureScreenshotResult: ...
109
+ async def close_session(self, input: t.SessionIdInput) -> t.CloseSessionResult: ...
110
+ async def create_context(self, input: t.CreateContextInput) -> t.CreateContextResult: ...
111
+ async def create_session(self, input: t.CreateSessionInput) -> t.CreateSessionResult: ...
112
+ async def delete_context(self, input: t.DeleteContextInput) -> t.DeleteContextResult: ...
113
+ async def get_download(self, input: t.GetDownloadInput) -> t.GetDownloadResult: ...
114
+ async def get_session(self, input: t.SessionIdInput) -> t.SessionDTO: ...
115
+ async def get_session_logs(self, input: t.GetSessionLogsInput) -> t.GetSessionLogsResult: ...
116
+ async def get_session_replay(
117
+ self, input: t.GetSessionReplayInput
118
+ ) -> t.GetSessionReplayResult: ...
119
+ async def list_downloads(self, input: t.ListDownloadsInput) -> t.ListDownloadsResult: ...
120
+ async def run_actions(self, input: t.RunActionsInput) -> t.RunActionsResult: ...
121
+ async def upload_files(self, input: t.UploadFilesInput) -> t.UploadFilesResult: ...
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class Deps:
126
+ browserbase: Port
127
+
128
+
129
+ class Client:
130
+ def __init__(self, http: httpx.AsyncClient, env: Mapping[str, str] | None = None) -> None:
131
+ self.env = os.environ if env is None else env
132
+ self.http = http
133
+ self.api = HttpClient(
134
+ http, "https://api.browserbase.com/v1", auth=self._auth, label="Browserbase API"
135
+ )
136
+
137
+ def _auth(self) -> dict[str, str]:
138
+ key = self.env.get("BROWSERBASE_API_KEY")
139
+ if not key:
140
+ raise ValueError("BROWSERBASE_API_KEY is required for the browserbase.* tasks.")
141
+ return {"x-bb-api-key": key, "Accept": "application/json"}
142
+
143
+ async def create_context(self, input: t.CreateContextInput) -> t.CreateContextResult:
144
+ project = input.get("projectId", self.env.get("BROWSERBASE_PROJECT_ID"))
145
+ raw = await self.api.call(
146
+ "/contexts", method="POST", body={"projectId": project} if project else {}
147
+ )
148
+ return {"contextId": raw["id"]}
149
+
150
+ async def delete_context(self, input: t.DeleteContextInput) -> t.DeleteContextResult:
151
+ deleted = True
152
+ try:
153
+ await self.api.call(
154
+ "/contexts/" + enc(input["contextId"]), method="DELETE", headers={"Accept": "*/*"}
155
+ )
156
+ except ApiError as error:
157
+ if error.status != 404:
158
+ raise
159
+ deleted = False
160
+ return {"contextId": input["contextId"], "deleted": deleted}
161
+
162
+ async def create_session(self, input: t.CreateSessionInput) -> t.CreateSessionResult:
163
+ key = input.get("idempotencyKey", "").strip()
164
+ if not key:
165
+ raise ValueError(
166
+ "createSession requires a non-empty idempotencyKey so a retry can "
167
+ "find the existing session instead of starting a second paid one."
168
+ )
169
+ timeout = input.get("timeoutSeconds")
170
+ if timeout is not None and not integer(timeout, 60, 21600):
171
+ raise ValueError(
172
+ f"timeoutSeconds must be an integer between 60 and 21600, received {timeout}."
173
+ )
174
+ viewport = input.get("viewport")
175
+ if viewport is not None and not all(integer(viewport[k], 1) for k in ("width", "height")):
176
+ raise ValueError(
177
+ f"viewport width and height must be positive integers, received "
178
+ f"{viewport['width']}x{viewport['height']}."
179
+ )
180
+ existing = await self.api.call(
181
+ "/sessions?" + query({"q": f"user_metadata['{IDEMPOTENCY_METADATA_KEY}']:'{key}'"})
182
+ )
183
+ if existing:
184
+ return {**session(existing[0]), "created": False}
185
+ body = pick(input, "region", "keepAlive", "proxies")
186
+ project = input.get("projectId", self.env.get("BROWSERBASE_PROJECT_ID"))
187
+ if project is not None:
188
+ body["projectId"] = project
189
+ if timeout is not None:
190
+ body["timeout"] = timeout
191
+ body["userMetadata"] = {**input.get("userMetadata", {}), IDEMPOTENCY_METADATA_KEY: key}
192
+ settings = pick(
193
+ input,
194
+ "blockAds",
195
+ "solveCaptchas",
196
+ "recordSession",
197
+ "logSession",
198
+ "viewport",
199
+ "allowedDomains",
200
+ )
201
+ if "contextId" in input:
202
+ settings["context"] = {
203
+ "id": input["contextId"],
204
+ "persist": input.get("persistContext", False),
205
+ }
206
+ if settings:
207
+ body["browserSettings"] = settings
208
+ return {
209
+ **session(await self.api.call("/sessions", method="POST", body=body)),
210
+ "created": True,
211
+ }
212
+
213
+ async def get_session(self, input: t.SessionIdInput) -> t.SessionDTO:
214
+ return session(await self.api.call("/sessions/" + enc(input["sessionId"])))
215
+
216
+ async def close_session(self, input: t.SessionIdInput) -> t.CloseSessionResult:
217
+ current = await self.get_session(input)
218
+ changed = current["status"] not in ("ERROR", "TIMED_OUT", "COMPLETED")
219
+ if changed:
220
+ body = {"status": "REQUEST_RELEASE"}
221
+ if self.env.get("BROWSERBASE_PROJECT_ID"):
222
+ body["projectId"] = self.env["BROWSERBASE_PROJECT_ID"]
223
+ current = session(
224
+ await self.api.call(
225
+ "/sessions/" + enc(input["sessionId"]), method="POST", body=body
226
+ )
227
+ )
228
+ return {"sessionId": current["sessionId"], "status": current["status"], "changed": changed}
229
+
230
+ @asynccontextmanager
231
+ async def page(self, session_id: str) -> AsyncIterator[Any]:
232
+ from playwright.async_api import async_playwright
233
+
234
+ debug = await self.api.call("/sessions/" + enc(session_id) + "/debug")
235
+ async with async_playwright() as playwright:
236
+ browser = await playwright.chromium.connect_over_cdp(debug["wsUrl"])
237
+ try:
238
+ if not browser.contexts:
239
+ raise ValueError("No browser context on the session")
240
+ if not browser.contexts[0].pages:
241
+ raise ValueError("No page on the session")
242
+ yield browser.contexts[0].pages[0]
243
+ finally:
244
+ await browser.close()
245
+
246
+ async def run_actions(self, input: t.RunActionsInput) -> t.RunActionsResult:
247
+ if not 1 <= len(input["actions"]) <= 50:
248
+ raise ValueError(
249
+ f"runActions accepts 1 to 50 actions, received {len(input['actions'])}."
250
+ )
251
+ timeout = input.get("actionTimeoutMs", 30000)
252
+ if not integer(timeout, 1, 120000):
253
+ raise ValueError(
254
+ f"actionTimeoutMs must be an integer between 1 and 120000, received {timeout}."
255
+ )
256
+ results: list[t.ActionResultDTO] = []
257
+ async with self.page(input["sessionId"]) as page:
258
+ page.set_default_timeout(timeout)
259
+ for index, action in enumerate(input["actions"]):
260
+ text = None
261
+ kind = action["type"]
262
+ if action["type"] == "goto":
263
+ await page.goto(action["url"], wait_until=action.get("waitUntil", "load"))
264
+ elif action["type"] == "press":
265
+ target = (
266
+ page.locator(action["selector"])
267
+ if action.get("selector")
268
+ else page.keyboard
269
+ )
270
+ await target.press(action["key"])
271
+ elif action["type"] == "click":
272
+ await page.locator(action["selector"]).click()
273
+ elif action["type"] == "fill":
274
+ await page.locator(action["selector"]).fill(action["value"])
275
+ elif action["type"] == "select":
276
+ await page.locator(action["selector"]).select_option(action["value"])
277
+ elif action["type"] == "waitForSelector":
278
+ await page.locator(action["selector"]).wait_for(
279
+ **({"state": action["state"]} if "state" in action else {})
280
+ )
281
+ elif action["type"] == "extractText":
282
+ text = bounded_text(
283
+ await page.locator(action["selector"]).inner_text() or "",
284
+ action.get("maxBytes", 262144),
285
+ )
286
+ else:
287
+ raise ValueError("Unsupported browser action")
288
+ results.append(
289
+ {
290
+ "index": index,
291
+ "type": kind,
292
+ "url": page.url,
293
+ "title": await page.title(),
294
+ "text": text,
295
+ }
296
+ )
297
+ return {"sessionId": input["sessionId"], "actions": results}
298
+
299
+ async def capture_screenshot(
300
+ self, input: t.CaptureScreenshotInput
301
+ ) -> t.CaptureScreenshotResult:
302
+ format = input.get("format", "png")
303
+ quality = input.get("quality")
304
+ if quality is not None:
305
+ if format != "jpeg":
306
+ raise ValueError("quality is only valid for jpeg screenshots.")
307
+ if not integer(quality, 0, 100):
308
+ raise ValueError(
309
+ f"quality must be an integer between 0 and 100, received {quality}."
310
+ )
311
+ maximum = validate_byte_limit(input.get("maxBytes", 524288), MAX_BINARY_BYTES)
312
+ async with self.page(input["sessionId"]) as page:
313
+ data = await page.screenshot(
314
+ full_page=input.get("fullPage", False),
315
+ type=format,
316
+ **({"quality": quality} if quality is not None else {}),
317
+ )
318
+ return {
319
+ "sessionId": input["sessionId"],
320
+ "screenshot": bounded_binary(data, "image/" + format, maximum),
321
+ }
322
+
323
+ async def upload_files(self, input: t.UploadFilesInput) -> t.UploadFilesResult:
324
+ if not input["files"]:
325
+ raise ValueError("uploadFiles requires at least one file.")
326
+ maximum = validate_byte_limit(input.get("maxTotalBytes", 1048576), MAX_BINARY_BYTES)
327
+ decoded = []
328
+ for file in input["files"]:
329
+ try:
330
+ data = base64.b64decode(
331
+ file["base64"] + "=" * (-len(file["base64"]) % 4), validate=True
332
+ )
333
+ if base64.b64encode(data).decode().rstrip("=") != file["base64"].rstrip("="):
334
+ raise ValueError("noncanonical base64")
335
+ except (ValueError, binascii.Error):
336
+ raise ValueError(f"File {file['name']} has invalid base64 content.") from None
337
+ decoded.append(data)
338
+ total = sum(map(len, decoded))
339
+ if total > maximum:
340
+ raise ValueError(
341
+ f"Uploads total {total} decoded bytes, above the maxTotalBytes "
342
+ f"limit of {maximum}. Upload fewer or smaller files."
343
+ )
344
+ for file, data in zip(input["files"], decoded, strict=True):
345
+ response = await self.http.post(
346
+ "https://api.browserbase.com/v1/sessions/" + enc(input["sessionId"]) + "/uploads",
347
+ headers=self._auth(),
348
+ files={"file": (file["name"], data, file["contentType"])},
349
+ )
350
+ if not response.is_success:
351
+ raise ApiError(response.status_code, "Browserbase file upload failed")
352
+ return {
353
+ "sessionId": input["sessionId"],
354
+ "files": [
355
+ {"name": file["name"], "contentType": file["contentType"], "byteCount": len(data)}
356
+ for file, data in zip(input["files"], decoded, strict=True)
357
+ ],
358
+ }
359
+
360
+ async def archive(self, session_id: str) -> zipfile.ZipFile:
361
+ # Keep compressed transfers bounded as well as extracted files.
362
+ async with self.http.stream(
363
+ "GET",
364
+ "https://api.browserbase.com/v1/sessions/" + enc(session_id) + "/downloads",
365
+ headers={**self._auth(), "Accept": "application/zip"},
366
+ ) as response:
367
+ if not response.is_success:
368
+ raise ApiError(response.status_code, "Browserbase downloads failed")
369
+ data = bytearray()
370
+ async for chunk in response.aiter_bytes():
371
+ if len(data) + len(chunk) > 104857600:
372
+ raise ValueError(
373
+ "Browserbase download archive exceeds the 100 MiB transfer limit."
374
+ )
375
+ data.extend(chunk)
376
+ return zipfile.ZipFile(io.BytesIO(data))
377
+
378
+ async def list_downloads(self, input: t.ListDownloadsInput) -> t.ListDownloadsResult:
379
+ limit = max(1, min(100, int(input.get("limit", 20))))
380
+ offset = max(0, int(input.get("offset", 0)))
381
+ with await self.archive(input["sessionId"]) as archive:
382
+ all = [download(input["sessionId"], info) for info in archive.infolist()]
383
+ all = [item for item in all if all_match(item, input, ("filename", "contentType"))]
384
+ items = all[offset : offset + limit]
385
+ return {
386
+ "sessionId": input["sessionId"],
387
+ "downloads": items,
388
+ "page": {
389
+ "returned": len(items),
390
+ "available": len(all),
391
+ "limit": limit,
392
+ "truncated": offset + len(items) < len(all),
393
+ },
394
+ }
395
+
396
+ async def get_download(self, input: t.GetDownloadInput) -> t.GetDownloadResult:
397
+ maximum = validate_byte_limit(input.get("maxBytes", 1048576), MAX_BINARY_BYTES)
398
+ try:
399
+ value = input["downloadId"]
400
+ parsed = json.loads(base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)))
401
+ session_id, name = parsed["sessionId"], parsed["name"]
402
+ if not isinstance(session_id, str) or not isinstance(name, str):
403
+ raise ValueError("invalid")
404
+ except (ValueError, KeyError, TypeError):
405
+ raise ValueError(f"Invalid downloadId: {input['downloadId']}") from None
406
+ with await self.archive(session_id) as archive:
407
+ try:
408
+ info = archive.getinfo(name)
409
+ except KeyError:
410
+ raise ValueError(f"Download not found: {name}") from None
411
+ metadata = download(session_id, info)
412
+ if info.file_size > maximum:
413
+ raise ValueError(
414
+ f"Download {name} is {info.file_size} bytes, above the maxBytes "
415
+ f"limit of {maximum}. Narrow the request or retrieve it from "
416
+ f"the Browserbase-managed download URL directly."
417
+ )
418
+ with archive.open(info) as file:
419
+ data = file.read(maximum + 1)
420
+ return {**metadata, "content": bounded_binary(data, metadata["contentType"], maximum)}
421
+
422
+ async def get_session_logs(self, input: t.GetSessionLogsInput) -> t.GetSessionLogsResult:
423
+ limit = max(1, min(500, int(input.get("limit", 100))))
424
+ maximum = validate_byte_limit(input.get("maxBytes", 262144), MAX_TEXT_BYTES)
425
+ raw = await self.api.call("/sessions/" + enc(input["sessionId"]) + "/logs")
426
+ logs: list[t.SessionLogDTO] = []
427
+ used = 0
428
+ for log in raw[:limit]:
429
+ if used >= maximum:
430
+ break
431
+ request = log.get("request") or {}
432
+ response = log.get("response") or {}
433
+ bounded_request = bounded_text(
434
+ serialize(request.get("params"), request.get("rawBody", "")), maximum - used
435
+ )
436
+ used += bounded_request["returnedBytes"]
437
+ # A zero remaining budget must not leak another response byte.
438
+ response_text = serialize(response.get("result"), response.get("rawBody", ""))
439
+ bounded_response: BoundedText
440
+ if used == maximum:
441
+ bounded_response = {
442
+ "text": "",
443
+ "byteCount": len(response_text.encode()),
444
+ "returnedBytes": 0,
445
+ "maxBytes": 0,
446
+ "truncated": bool(response_text),
447
+ }
448
+ else:
449
+ bounded_response = bounded_text(response_text, maximum - used)
450
+ used += bounded_response["returnedBytes"]
451
+ logs.append(
452
+ {
453
+ "method": log["method"],
454
+ "pageId": log["pageId"],
455
+ "timestamp": log.get("timestamp"),
456
+ "request": bounded_request,
457
+ "response": bounded_response,
458
+ }
459
+ )
460
+ return {
461
+ "sessionId": input["sessionId"],
462
+ "logs": logs,
463
+ "page": {
464
+ "returned": len(logs),
465
+ "available": len(raw),
466
+ "limit": limit,
467
+ "truncated": len(logs) < len(raw)
468
+ or any(log["request"]["truncated"] or log["response"]["truncated"] for log in logs),
469
+ },
470
+ }
471
+
472
+ async def get_session_replay(self, input: t.GetSessionReplayInput) -> t.GetSessionReplayResult:
473
+ limit = max(1, min(100, int(input.get("limit", 20))))
474
+ raw = await self.api.call("/sessions/" + enc(input["sessionId"]) + "/replays")
475
+ pages: list[t.ReplayPageDTO] = [
476
+ {
477
+ "pageId": p["pageId"],
478
+ "startedAt": iso(p["startTimeMs"]) if p.get("startTimeMs") is not None else None,
479
+ "endedAt": iso(p["endTimeMs"]) if p.get("endTimeMs") is not None else None,
480
+ "playlistUrl": p["url"],
481
+ }
482
+ for p in raw["pages"][:limit]
483
+ ]
484
+ return {
485
+ "sessionId": input["sessionId"],
486
+ "pages": pages,
487
+ "page": {
488
+ "returned": len(pages),
489
+ "available": len(raw["pages"]),
490
+ "limit": limit,
491
+ "truncated": len(pages) < len(raw["pages"]),
492
+ },
493
+ }
494
+
495
+
496
+ @asynccontextmanager
497
+ async def dependencies(deps: Deps | None) -> AsyncIterator[Deps]:
498
+ if deps is not None:
499
+ yield deps
500
+ else:
501
+ async with httpx.AsyncClient(
502
+ timeout=30, transport=httpx.AsyncHTTPTransport(retries=0)
503
+ ) as http:
504
+ yield Deps(browserbase=Client(http))
505
+
506
+
507
+ BrowserbasePort = Port
508
+ BrowserbaseClient = Client
509
+ BrowserbaseDeps = Deps
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_CONVERGENT_RETRY
6
+ from .types import CloseSessionResult, SessionIdInput
7
+
8
+
9
+ async def close_session_impl(
10
+ ctx: TaskContext, input: SessionIdInput, *, deps: Deps | None = None
11
+ ) -> CloseSessionResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.close_session(input)
14
+
15
+
16
+ @app.task(name="browserbase.closeSession", retry=BROWSERBASE_CONVERGENT_RETRY)
17
+ async def close_session(ctx: TaskContext, input: SessionIdInput) -> CloseSessionResult:
18
+ return await close_session_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_NO_RETRY
6
+ from .types import CreateContextInput, CreateContextResult
7
+
8
+
9
+ async def create_context_impl(
10
+ ctx: TaskContext, input: CreateContextInput, *, deps: Deps | None = None
11
+ ) -> CreateContextResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.create_context(input)
14
+
15
+
16
+ @app.task(name="browserbase.createContext", retry=BROWSERBASE_NO_RETRY)
17
+ async def create_context(ctx: TaskContext, input: CreateContextInput) -> CreateContextResult:
18
+ return await create_context_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_CONVERGENT_RETRY
6
+ from .types import CreateSessionInput, CreateSessionResult
7
+
8
+
9
+ async def create_session_impl(
10
+ ctx: TaskContext, input: CreateSessionInput, *, deps: Deps | None = None
11
+ ) -> CreateSessionResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.create_session(input)
14
+
15
+
16
+ @app.task(name="browserbase.createSession", retry=BROWSERBASE_CONVERGENT_RETRY)
17
+ async def create_session(ctx: TaskContext, input: CreateSessionInput) -> CreateSessionResult:
18
+ return await create_session_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_CONVERGENT_RETRY
6
+ from .types import DeleteContextInput, DeleteContextResult
7
+
8
+
9
+ async def delete_context_impl(
10
+ ctx: TaskContext, input: DeleteContextInput, *, deps: Deps | None = None
11
+ ) -> DeleteContextResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.delete_context(input)
14
+
15
+
16
+ @app.task(name="browserbase.deleteContext", retry=BROWSERBASE_CONVERGENT_RETRY)
17
+ async def delete_context(ctx: TaskContext, input: DeleteContextInput) -> DeleteContextResult:
18
+ return await delete_context_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_READ_RETRY
6
+ from .types import GetDownloadInput, GetDownloadResult
7
+
8
+
9
+ async def get_download_impl(
10
+ ctx: TaskContext, input: GetDownloadInput, *, deps: Deps | None = None
11
+ ) -> GetDownloadResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.get_download(input)
14
+
15
+
16
+ @app.task(name="browserbase.getDownload", retry=BROWSERBASE_READ_RETRY)
17
+ async def get_download(ctx: TaskContext, input: GetDownloadInput) -> GetDownloadResult:
18
+ return await get_download_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_READ_RETRY
6
+ from .types import SessionDTO, SessionIdInput
7
+
8
+
9
+ async def get_session_impl(
10
+ ctx: TaskContext, input: SessionIdInput, *, deps: Deps | None = None
11
+ ) -> SessionDTO:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.get_session(input)
14
+
15
+
16
+ @app.task(name="browserbase.getSession", retry=BROWSERBASE_READ_RETRY)
17
+ async def get_session(ctx: TaskContext, input: SessionIdInput) -> SessionDTO:
18
+ return await get_session_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_READ_RETRY
6
+ from .types import GetSessionLogsInput, GetSessionLogsResult
7
+
8
+
9
+ async def get_session_logs_impl(
10
+ ctx: TaskContext, input: GetSessionLogsInput, *, deps: Deps | None = None
11
+ ) -> GetSessionLogsResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.get_session_logs(input)
14
+
15
+
16
+ @app.task(name="browserbase.getSessionLogs", retry=BROWSERBASE_READ_RETRY)
17
+ async def get_session_logs(ctx: TaskContext, input: GetSessionLogsInput) -> GetSessionLogsResult:
18
+ return await get_session_logs_impl(ctx, input)
@@ -0,0 +1,20 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_READ_RETRY
6
+ from .types import GetSessionReplayInput, GetSessionReplayResult
7
+
8
+
9
+ async def get_session_replay_impl(
10
+ ctx: TaskContext, input: GetSessionReplayInput, *, deps: Deps | None = None
11
+ ) -> GetSessionReplayResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.get_session_replay(input)
14
+
15
+
16
+ @app.task(name="browserbase.getSessionReplay", retry=BROWSERBASE_READ_RETRY)
17
+ async def get_session_replay(
18
+ ctx: TaskContext, input: GetSessionReplayInput
19
+ ) -> GetSessionReplayResult:
20
+ return await get_session_replay_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_READ_RETRY
6
+ from .types import ListDownloadsInput, ListDownloadsResult
7
+
8
+
9
+ async def list_downloads_impl(
10
+ ctx: TaskContext, input: ListDownloadsInput, *, deps: Deps | None = None
11
+ ) -> ListDownloadsResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.list_downloads(input)
14
+
15
+
16
+ @app.task(name="browserbase.listDownloads", retry=BROWSERBASE_READ_RETRY)
17
+ async def list_downloads(ctx: TaskContext, input: ListDownloadsInput) -> ListDownloadsResult:
18
+ return await list_downloads_impl(ctx, input)
@@ -0,0 +1,5 @@
1
+ from render import Retry
2
+
3
+ BROWSERBASE_NO_RETRY = Retry(max_retries=0, wait_duration_ms=0, backoff_scaling=1)
4
+ BROWSERBASE_CONVERGENT_RETRY = Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=2)
5
+ BROWSERBASE_READ_RETRY = Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=2)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_NO_RETRY
6
+ from .types import RunActionsInput, RunActionsResult
7
+
8
+
9
+ async def run_actions_impl(
10
+ ctx: TaskContext, input: RunActionsInput, *, deps: Deps | None = None
11
+ ) -> RunActionsResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.run_actions(input)
14
+
15
+
16
+ @app.task(name="browserbase.runActions", retry=BROWSERBASE_NO_RETRY)
17
+ async def run_actions(ctx: TaskContext, input: RunActionsInput) -> RunActionsResult:
18
+ return await run_actions_impl(ctx, input)
@@ -0,0 +1,41 @@
1
+ from ._app import app
2
+ from .capture_screenshot import capture_screenshot, capture_screenshot_impl
3
+ from .close_session import close_session, close_session_impl
4
+ from .create_context import create_context, create_context_impl
5
+ from .create_session import create_session, create_session_impl
6
+ from .delete_context import delete_context, delete_context_impl
7
+ from .get_download import get_download, get_download_impl
8
+ from .get_session import get_session, get_session_impl
9
+ from .get_session_logs import get_session_logs, get_session_logs_impl
10
+ from .get_session_replay import get_session_replay, get_session_replay_impl
11
+ from .list_downloads import list_downloads, list_downloads_impl
12
+ from .run_actions import run_actions, run_actions_impl
13
+ from .upload_files import upload_files, upload_files_impl
14
+
15
+ __all__ = [
16
+ "app",
17
+ "capture_screenshot",
18
+ "capture_screenshot_impl",
19
+ "close_session",
20
+ "close_session_impl",
21
+ "create_context",
22
+ "create_context_impl",
23
+ "create_session",
24
+ "create_session_impl",
25
+ "delete_context",
26
+ "delete_context_impl",
27
+ "get_download",
28
+ "get_download_impl",
29
+ "get_session",
30
+ "get_session_impl",
31
+ "get_session_logs",
32
+ "get_session_logs_impl",
33
+ "get_session_replay",
34
+ "get_session_replay_impl",
35
+ "list_downloads",
36
+ "list_downloads_impl",
37
+ "run_actions",
38
+ "run_actions_impl",
39
+ "upload_files",
40
+ "upload_files_impl",
41
+ ]
@@ -0,0 +1,288 @@
1
+ """JSON contracts ported from the pinned TypeScript pack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, NotRequired, TypedDict
6
+
7
+ from render_lab_tasks_core.bounded import BoundedBinary, BoundedText
8
+
9
+
10
+ class UploadFilesResultFilesItem(TypedDict):
11
+ name: str
12
+ contentType: str
13
+ byteCount: float
14
+
15
+
16
+ class BrowserActionValueVariant7(TypedDict):
17
+ type: Literal["extractText"]
18
+ selector: str
19
+ maxBytes: NotRequired[float]
20
+
21
+
22
+ class BrowserActionValueVariant6(TypedDict):
23
+ type: Literal["waitForSelector"]
24
+ selector: str
25
+ state: NotRequired[
26
+ Literal["attached"] | Literal["detached"] | Literal["visible"] | Literal["hidden"]
27
+ ]
28
+
29
+
30
+ class BrowserActionValueVariant5(TypedDict):
31
+ type: Literal["select"]
32
+ selector: str
33
+ value: str
34
+
35
+
36
+ class BrowserActionValueVariant4(TypedDict):
37
+ type: Literal["press"]
38
+ selector: NotRequired[str]
39
+ key: str
40
+
41
+
42
+ class BrowserActionValueVariant3(TypedDict):
43
+ type: Literal["fill"]
44
+ selector: str
45
+ value: str
46
+
47
+
48
+ class BrowserActionValueVariant2(TypedDict):
49
+ type: Literal["click"]
50
+ selector: str
51
+
52
+
53
+ class BrowserActionValueVariant1(TypedDict):
54
+ type: Literal["goto"]
55
+ url: str
56
+ waitUntil: NotRequired[Literal["load"] | Literal["domcontentloaded"] | Literal["networkidle"]]
57
+
58
+
59
+ class CreateSessionInputViewport(TypedDict):
60
+ width: float
61
+ height: float
62
+
63
+
64
+ type SessionStatus = (
65
+ Literal["PENDING"]
66
+ | Literal["RUNNING"]
67
+ | Literal["ERROR"]
68
+ | Literal["TIMED_OUT"]
69
+ | Literal["COMPLETED"]
70
+ )
71
+
72
+ type BrowserbaseRegion = (
73
+ Literal["us-west-2"]
74
+ | Literal["us-east-1"]
75
+ | Literal["eu-central-1"]
76
+ | Literal["ap-southeast-1"]
77
+ )
78
+
79
+
80
+ class ContextDTO(TypedDict):
81
+ contextId: str
82
+ projectId: str
83
+ createdAt: str
84
+ updatedAt: str
85
+
86
+
87
+ class CreateContextInput(TypedDict):
88
+ projectId: NotRequired[str]
89
+
90
+
91
+ class CreateContextResult(TypedDict):
92
+ contextId: str
93
+
94
+
95
+ class DeleteContextInput(TypedDict):
96
+ contextId: str
97
+
98
+
99
+ class DeleteContextResult(TypedDict):
100
+ contextId: str
101
+ deleted: bool
102
+
103
+
104
+ class SessionDTO(TypedDict):
105
+ sessionId: str
106
+ projectId: str
107
+ status: SessionStatus
108
+ contextId: str | None
109
+ region: BrowserbaseRegion
110
+ keepAlive: bool
111
+ createdAt: str
112
+ updatedAt: str
113
+ startedAt: str
114
+ expiresAt: str
115
+
116
+
117
+ class CreateSessionInput(TypedDict):
118
+ idempotencyKey: str
119
+ projectId: NotRequired[str]
120
+ contextId: NotRequired[str]
121
+ persistContext: NotRequired[bool]
122
+ region: NotRequired[BrowserbaseRegion]
123
+ keepAlive: NotRequired[bool]
124
+ timeoutSeconds: NotRequired[float]
125
+ proxies: NotRequired[bool]
126
+ blockAds: NotRequired[bool]
127
+ solveCaptchas: NotRequired[bool]
128
+ recordSession: NotRequired[bool]
129
+ logSession: NotRequired[bool]
130
+ viewport: NotRequired[CreateSessionInputViewport]
131
+ allowedDomains: NotRequired[list[str]]
132
+ userMetadata: NotRequired[dict[str, str]]
133
+
134
+
135
+ class CreateSessionResult(SessionDTO):
136
+ created: bool
137
+
138
+
139
+ class SessionIdInput(TypedDict):
140
+ sessionId: str
141
+
142
+
143
+ class CloseSessionResult(TypedDict):
144
+ sessionId: str
145
+ status: SessionStatus
146
+ changed: bool
147
+
148
+
149
+ type BrowserAction = (
150
+ BrowserActionValueVariant1
151
+ | BrowserActionValueVariant2
152
+ | BrowserActionValueVariant3
153
+ | BrowserActionValueVariant4
154
+ | BrowserActionValueVariant5
155
+ | BrowserActionValueVariant6
156
+ | BrowserActionValueVariant7
157
+ )
158
+
159
+
160
+ class RunActionsInput(SessionIdInput):
161
+ actions: list[BrowserAction]
162
+ actionTimeoutMs: NotRequired[float]
163
+
164
+
165
+ class ActionResultDTO(TypedDict):
166
+ index: float
167
+ type: Literal["goto", "click", "fill", "press", "select", "waitForSelector", "extractText"]
168
+ url: str
169
+ title: str
170
+ text: BoundedText | None
171
+
172
+
173
+ class RunActionsResult(TypedDict):
174
+ sessionId: str
175
+ actions: list[ActionResultDTO]
176
+
177
+
178
+ class CaptureScreenshotInput(SessionIdInput):
179
+ fullPage: NotRequired[bool]
180
+ format: NotRequired[Literal["png"] | Literal["jpeg"]]
181
+ quality: NotRequired[float]
182
+ maxBytes: NotRequired[float]
183
+
184
+
185
+ class CaptureScreenshotResult(TypedDict):
186
+ sessionId: str
187
+ screenshot: BoundedBinary
188
+
189
+
190
+ class UploadFileInput(TypedDict):
191
+ name: str
192
+ contentType: str
193
+ base64: str
194
+
195
+
196
+ class UploadFilesInput(SessionIdInput):
197
+ files: list[UploadFileInput]
198
+ maxTotalBytes: NotRequired[float]
199
+
200
+
201
+ class UploadFilesResult(TypedDict):
202
+ sessionId: str
203
+ files: list[UploadFilesResultFilesItem]
204
+
205
+
206
+ class DownloadDTO(TypedDict):
207
+ downloadId: str
208
+ sessionId: str
209
+ filename: str
210
+ contentType: str
211
+ byteCount: float
212
+ checksum: str
213
+ createdAt: str
214
+
215
+
216
+ class ListDownloadsInput(SessionIdInput):
217
+ filename: NotRequired[str]
218
+ contentType: NotRequired[str]
219
+ limit: NotRequired[float]
220
+ offset: NotRequired[float]
221
+
222
+
223
+ class ListDownloadsResult(TypedDict):
224
+ sessionId: str
225
+ downloads: list[DownloadDTO]
226
+ page: BoundedCollection
227
+
228
+
229
+ class GetDownloadInput(TypedDict):
230
+ downloadId: str
231
+ maxBytes: NotRequired[float]
232
+
233
+
234
+ class GetDownloadResult(DownloadDTO):
235
+ content: BoundedBinary
236
+
237
+
238
+ class GetSessionLogsInput(SessionIdInput):
239
+ limit: NotRequired[float]
240
+ maxBytes: NotRequired[float]
241
+
242
+
243
+ class SessionLogDTO(TypedDict):
244
+ method: str
245
+ pageId: float
246
+ timestamp: float | None
247
+ request: BoundedText
248
+ response: BoundedText
249
+
250
+
251
+ class GetSessionLogsResult(TypedDict):
252
+ sessionId: str
253
+ logs: list[SessionLogDTO]
254
+ page: BoundedCollection
255
+
256
+
257
+ class GetSessionReplayInput(SessionIdInput):
258
+ limit: NotRequired[float]
259
+
260
+
261
+ class ReplayPageDTO(TypedDict):
262
+ pageId: str
263
+ startedAt: str | None
264
+ endedAt: str | None
265
+ playlistUrl: str
266
+
267
+
268
+ class GetSessionReplayResult(TypedDict):
269
+ sessionId: str
270
+ pages: list[ReplayPageDTO]
271
+ page: BoundedCollection
272
+
273
+
274
+ class RawSessionLog(TypedDict):
275
+ method: str
276
+ pageId: float
277
+ timestamp: float | None
278
+ requestParams: Any
279
+ requestBody: str
280
+ responseResult: Any
281
+ responseBody: str
282
+
283
+
284
+ class BoundedCollection(TypedDict):
285
+ returned: int
286
+ available: int | None
287
+ limit: int
288
+ truncated: bool
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import BROWSERBASE_NO_RETRY
6
+ from .types import UploadFilesInput, UploadFilesResult
7
+
8
+
9
+ async def upload_files_impl(
10
+ ctx: TaskContext, input: UploadFilesInput, *, deps: Deps | None = None
11
+ ) -> UploadFilesResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.browserbase.upload_files(input)
14
+
15
+
16
+ @app.task(name="browserbase.uploadFiles", retry=BROWSERBASE_NO_RETRY)
17
+ async def upload_files(ctx: TaskContext, input: UploadFilesInput) -> UploadFilesResult:
18
+ return await upload_files_impl(ctx, input)