Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,20 @@ jobs:
- run: pnpm test

e2e:
name: Actor dev-loop e2e (apify-cli + Docker)
name: e2e ${{ matrix.file }} (apify-cli + Docker)
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
# One job per e2e file. Each file starts its own runtime container on the fixed host ports
# (3333/3000), so files cannot share a daemon; separate runners make them parallel instead.
matrix:
file:
- actor-dev-loop
- debug-mode
- dev-folder-bind-mount
- browser-view-ts
- browser-view-py
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
Expand All @@ -40,10 +51,13 @@ jobs:
# The suite manages Docker itself against the runner's daemon: it pre-pulls the
# Actor base images, builds the runtime image, starts the runtime container with
# the host Docker socket, and drives it with stock apify-cli via npx.
- run: pnpm run test:e2e
- run: pnpm exec vitest run test/e2e/${{ matrix.file }}.test.ts
# The e2e's runtime container is normally removed by the suite's afterAll; on
# failure it is left running, so its server-side view of any failed request
# (log-stream lifecycle included) is captured here for diagnosis.
- name: Dump runtime container logs on failure
if: failure()
run: docker logs actor-runtime-e2e --tail 300 || true
run: |
for c in $(docker ps -aq --filter name=actor-runtime-e2e); do
docker logs --tail 300 "$c" || true
done
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ dist/
*.tsbuildinfo
sample_actor_ts/node_modules/
sample_actor_ts/dist/
sample_actor_playwright/node_modules/
sample_actor_playwright/dist/
sample_actor_py/__pycache__/
sample_actor_py/.venv/
.npm/
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.MD
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ Local Actor runtime is an Actor development tool for developing, running, and de
when you expect a slow attach. Clear the toggle with
`apify api POST /actor-runtime/debug/<actorId> --body '{"enabled": false}'` to go back to running
normally.
- To watch the browser of a Playwright/Puppeteer Actor while it runs, turn browser view on for it once:
`apify api POST /actor-runtime/browser-view/<actorId> --body '{"enabled": true}'` (`"interactive": true` also
sends mouse/keyboard input). Every subsequent run prints a viewer URL in its log,
`http://localhost:3000/runs/<runId>/browser` - a live view of the display the Actor's browser draws on. The
browser must run headful to show anything (Apify's templates default to headless, which shows as a black
display); see `sample_actor_playwright` and `sample_actor_playwright_py`. Clear with `--body '{"enabled": false}'`.
- To test how an Actor handles platform migrations: while a run is `RUNNING`, call
`apify api POST /actor-runtime/migrate/<runId>`, or press the Migrate button on the run's console
detail page. The run gets the platform migration experience: a `migrating` event, its container
Expand Down
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ import debugpy._version as v; \
print(v.get_versions()['version'])" > /payload/debugpy-version.txt
RUN tar -cf /payload/debugpy-payload.tar -C /payload/root .

# --- Browser-view sidecar: an Alpine rootfs with x11vnc, tarred so the runtime can `docker import` it at
# run time without a registry. Not pinned to $BUILDPLATFORM: it runs on the Actor containers' daemon, so it
# must be the target architecture's.
FROM alpine:3.21 AS browser-viewer-rootfs
RUN apk add --no-cache x11vnc
RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix
COPY docker/browser-viewer.sh /apify-browser-viewer.sh
RUN chmod 755 /apify-browser-viewer.sh

# Tars the stage above and records its content hash, which the runtime uses as the imported image's tag.
FROM --platform=$BUILDPLATFORM alpine:3.21 AS browser-viewer-payload
COPY --from=browser-viewer-rootfs / /rootfs
RUN mkdir -p /payload \
&& tar -cf /payload/rootfs.tar -C /rootfs . \
&& sha256sum /payload/rootfs.tar | cut -c1-16 > /payload/version.txt

# Also architecture-independent: this stage only runs `tsc`, and the `dist/` it hands to the final
# stage is plain JavaScript. The final stage does its own `pnpm install --prod`, so the target
# architecture's native bindings still come from a native (emulated) install there.
Expand Down Expand Up @@ -64,6 +80,10 @@ COPY --from=builder /usr/src/app/dist ./dist
COPY --from=debugpy-payload /payload/debugpy-payload.tar /opt/apify-debug-payload/debugpy-payload.tar
COPY --from=debugpy-payload /payload/debugpy-version.txt /opt/apify-debug-payload/debugpy-version.txt

# Matches config.ts's browserViewerPayloadDir() default.
COPY --from=browser-viewer-payload /payload/rootfs.tar /opt/apify-browser-viewer/rootfs.tar
COPY --from=browser-viewer-payload /payload/version.txt /opt/apify-browser-viewer/version.txt

# The runtime talks to the host Docker socket via dockerode (no docker CLI needed in-image) and
# persists all storages under /data - mount both when running the container.
VOLUME ["/data"]
Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,27 @@ three-field form (`enabled`/`language`/`port`) on the Actor's page in the consol
`requirements/actor-driver.md`'s "Debug mode" section; endpoint/console details: `requirements/api.md`'s
`/actor-runtime/*` section and `requirements/console.md`.

## Watching an Actor's browser

Turn **browser view** on for an Actor once, and every run of it gets a live view of the display its browser draws
on, served by the console:

```bash
apify api POST /actor-runtime/browser-view/<actorId> --body '{"enabled": true}'
apify call
```

The run log prints the viewer URL (`http://localhost:3000/runs/<runId>/browser`); the run's console page links to
it, and the Actor's console page has the same toggle as a form. `"interactive": true` also sends your mouse and
keyboard to the display; `{"enabled": false}` turns the view off.

The view only reads the display's pixels. The Actor's container, command, environment, network and ports are
those of an ordinary run, so neither the browser nor the sites it visits can tell whether anyone is watching.
Two things follow: the browser must run **headful** (Apify's templates default to headless, which shows as a
black display - the bundled `sample_actor_playwright` and `sample_actor_playwright_py` set `headless: false` /
`headless=False`), and the image must provide an X display, which the Apify Playwright and Puppeteer base images
do. Like Python debug mode, this needs the runtime to run from its own built image.

## Publishing the image

Images go to [`apify/actor-runtime`](https://hub.docker.com/r/apify/actor-runtime) on Docker Hub by
Expand All @@ -140,7 +161,7 @@ added by hand.
pnpm install
pnpm run build # tsc
pnpm test # unit + integration (no Docker needed)
pnpm run test:e2e # full CLI-driven dev loop against a built image (requires Docker)
pnpm run test:e2e # full CLI-driven dev loop against a built image (requires Docker; the browser-view case pulls the ~2 GB Playwright base image)
pnpm run dev # run the server directly against ./data with tsx
```

Expand Down
44 changes: 44 additions & 0 deletions docker/browser-viewer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/bin/sh
# actor-runtime's browser-view sidecar: waits for the Actor's X socket in the shared /tmp/.X11-unix, then
# mirrors that display with x11vnc. The display number is taken from the socket name (`xvfb-run -a` picks
# one at run time). When the Actor container restarts (migration), x11vnc exits and the loop waits again.
# Env names must match `src/driver/docker-driver.ts`.

SOCKET_DIR=/tmp/.X11-unix
PORT="${APIFY_BROWSER_VIEWER_PORT:-5900}"
if [ "$APIFY_BROWSER_VIEWER_INTERACTIVE" = "1" ]; then
INPUT_FLAG=""
MODE=interactive
else
INPUT_FLAG="-viewonly"
MODE=view-only
fi

log() {
echo "[actor-runtime browser view] $*"
}

log "waiting for an X display socket in $SOCKET_DIR (created by the Actor's own Xvfb when the Actor starts)"
while :; do
socket=""
for candidate in "$SOCKET_DIR"/X*; do
if [ -S "$candidate" ]; then
socket="$candidate"
break
fi
done
if [ -z "$socket" ]; then
sleep 0.5
continue
fi

display=":${socket##*/X}"
log "mirroring display $display ($MODE) on port $PORT"
# -noshm: MIT-SHM cannot cross container IPC namespaces (x11vnc would die on X_ShmAttach).
# -nosel/-nobell/-noxrecord/-nowf/-noscr: only read pixels; no clipboard, bell, or X request recording.
# shellcheck disable=SC2086 # INPUT_FLAG is intentionally word-split.
x11vnc -display "$display" -rfbport "$PORT" -noshm -nosel -nobell -shared -forever -nopw -noipv6 -q \
-noxrecord -nowf -noscr $INPUT_FLAG
log "x11vnc exited - display gone; waiting for a display again"
sleep 1
done
9 changes: 8 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import prettier from 'eslint-config-prettier';

export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', 'sample_actor_ts/**', 'sample_actor_py/**', 'data/**'],
ignores: [
'dist/**',
'node_modules/**',
'sample_actor_ts/**',
'sample_actor_py/**',
'sample_actor_playwright/**',
'data/**',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"dependencies": {
"@crawlee/core": "4.0.0-beta.145",
"@crawlee/fs-storage": "4.0.0-beta.145",
"@novnc/novnc": "1.7.0",
"dockerode": "^4.0.5",
"express": "^5.1.0",
"json5": "^2.2.3",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions requirements/actor-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,24 @@ start`, ...) is refused by name, naming both the `CMD` fix and how to clear debu
apply to the same run when both are configured for an Actor, e.g. edit -> recompile -> `apify call` ->
breakpoint, with no rebuild in between.

# Browser view

- Browser view is a persistent per-Actor toggle. While it is on, every run of the Actor offers a live view
of the display its browser draws on, reachable from the run's console page and from a URL printed in the
run log.
- Two modes: **view-only** (the default) shows the display and sends nothing to it; **interactive** also
delivers the viewer's mouse and keyboard input to the display. Nothing else ever crosses in either
direction (no clipboard).
- Watching is not observable from inside the browser or by the sites it visits: the run's container,
command, environment, network and ports are those of an ordinary run, and whether the view is on, off, or
being watched changes nothing about the browser.
- The runtime never changes the browser's headless mode. A headless browser shows an empty display; an
Actor that wants to be watched runs its browser headful (the bundled `sample_actor_playwright` and
`sample_actor_playwright_py` do). The Actor image must provide an X display; the Apify Playwright and
Puppeteer base images do.
- The view lives exactly as long as the run, survives a migration/reboot of the run, and is gone once the run
ends. It composes with debug mode and the dev-folder bind mount.

# Networking

- On startup, the runtime ensures a Docker network `apify-local` exists and joins it under the fixed
Expand Down
6 changes: 6 additions & 0 deletions requirements/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@
- The console's own debug-mode form (`console.md`) does **not** go through this endpoint - same
console-local, unauthenticated split as the dev-folder form - but both surfaces accept and reject
exactly the same inputs with the same outcomes.
- **`POST /actor-runtime/browser-view/:actorId`** - sets or clears the Actor's browser-view toggle
(`actor-driver.md`'s "Browser view" section). Authenticated and owner-scoped like every `/v2` route; no
build-first precondition.
- **Body**: `{ "enabled": boolean, "interactive"?: boolean }`, `interactive` defaulting to `false`. A call
fully replaces the prior state; `{"enabled": false}` clears it. Any other shape is `400 invalid-request`.
- **Response**: `{ data: { localBrowserView: { interactive } | null } }` - the read-back; there is no `GET`.
- **`GET /actor-runtime/events/:runId`** - a websocket upgrade, reachable at exactly this one path on
the fixed API port (`system.md`). It carries the run's platform events: `systemInfo` once a second
(`actor-driver.md`), a one-off `aborting`-plus-`persistState` pair under `?gracefully=` (below), and a
Expand Down
21 changes: 17 additions & 4 deletions requirements/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
- The console has no login of its own, so with multiple users it lists and shows every user's objects
rather than scoping to one - the API's own endpoints stay strictly scoped to the calling token's user
(`storage.md`'s "Users" section).
- The console is unauthenticated. Every route is a read except the console's only four writes: the
dev-folder form, the debug-mode form, the run detail view's Migrate button, and the Settings form
(all below).
- All four of those writes reject a submission that identifies itself as cross-site (via the
- The console is unauthenticated. Every route is a read except the console's only five writes: the
dev-folder form, the debug-mode form, the browser-view form, the run detail view's Migrate button, and
the Settings form (all below).
- All five of those writes reject a submission that identifies itself as cross-site (via the
`Sec-Fetch-Site` header) with a plain `403`; a submission that does not is unaffected.
- There are three types of objects: key-value store, dataset, request queue.
- For each object type there must be exactly one widget for inspection.
Expand Down Expand Up @@ -40,6 +40,8 @@
- A run whose debug plan resolved (`actor-driver.md`'s "Debug mode" section) gets one extra row on its
detail view: `debug` - `<language>, attach at 127.0.0.1:<port>`. Absent entirely for a non-debug run.
This field is local-only and never appears in the emulated `/v2` run object (`api.md`).
- A run with browser view (`actor-driver.md`) gets one extra row on its detail view: `browser view` - a
link to its viewer page (below). Absent for other runs; never in the emulated `/v2` run object.
- Log views render ANSI colors from actor output as HTML, while the `/v2/logs/:id` API keeps serving logs raw (unconverted) for the CLI to render itself.
- The console accepts the real Apify Console's URL shapes (as printed by stock apify-cli, e.g. `/actors/:actorId/runs/:runId`, `/storage/datasets/:id`) via redirects to its own pages.

Expand All @@ -66,6 +68,17 @@
- A submission that fails validation redirects back to the same detail page with the classified error
message shown inline, never silently applied.

## Browser-view form (Actor detail view)

- The Actor detail view shows the browser-view toggle status and a form with the API body's two fields,
`enabled` and `interactive`, as checkboxes. For any input, the form and the API produce the same outcome.

## Browser view page (`/runs/:runId/browser`)

- Shows the run's live display, view-only or interactive per the run's toggle, and says which. It reconnects
on its own while the run's browser is still starting.
- For a run that has ended, or never had browser view, the page says so instead.

## Migrate button (run detail view)

- The run detail view shows the run's `migrationCount` and `rebootCount`, and a "Migration" section:
Expand Down
8 changes: 6 additions & 2 deletions requirements/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@
(`actor-driver.md`). When present: `{ language: "auto" | "node" | "python", port?: number }` -
`port` absent means "resolve the language's own default port at run start", never a stored
literal (`actor-driver.md`).
- Neither `localDevFolder`, `localDebug`, nor any build's `imageWorkingDirectory` is ever exposed
on the public `/v2` API.
- `localBrowserView` - **optional**, `{ interactive: boolean }`; absent means browser view is off.
Same rules as `localDebug`: set only through its endpoint or console form, never bumping `modifiedAt`.
- Neither `localDevFolder`, `localDebug`, `localBrowserView`, nor any build's
`imageWorkingDirectory` is ever exposed on the public `/v2` API.
- The system stores Actor runs in dedicated key-value store called `__RUNS__`:
- `key` is the id of the Actor run `runId`
- `value` is the metadata of the Actor
Expand All @@ -70,6 +72,8 @@
number }`, both already resolved (never `"auto"`, never absent-meaning-default). Absent for
every non-debug run, and for a debug run that was refused before a plan could be resolved.
Never exposed on the emulated `/v2` run object.
- `localBrowserView` - **optional**, specific to this one run: `{ interactive, vncHost, vncPort }`,
the run's browser view once it is up. Absent otherwise. Never exposed on the emulated `/v2` run object.
- The system stores Actor builds in dedicated key-value store called `__BUILDS__`:
- `key` is the id of the Actor build (`buildId`)
- `value` is the metadata of the Actor
Expand Down
1 change: 1 addition & 0 deletions requirements/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
port published on the host, bound to `127.0.0.1` (`5678` Python / `9229` Node by default, per-Actor
overridable) - the runtime's own two ports above are unaffected, and no port is published for an Actor
that never turned debug mode on.
- Browser view (`actor-driver.md`) publishes no port on the host; the view is served on the console's port 3000.
- Required `docker run` flags: mount the host Docker socket read-write
(`-v /var/run/docker.sock:/var/run/docker.sock`) so the runtime can build and run Actor containers,
and mount a persistent data directory (`-v <host-dir>:/data`, e.g. `-v "$(pwd)/data:/data"`) so
Expand Down
Loading
Loading