├── .dockerignore ├── .env.example ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── build-docker.yml │ └── check-build.yml ├── .gitignore ├── .husky └── commit-msg ├── LICENSE ├── README.md ├── api ├── .dockerignore ├── .env.example ├── .gitattributes ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .puppeteerrc.cjs ├── Dockerfile ├── entrypoint.sh ├── extensions │ └── recorder │ │ ├── .gitignore │ │ ├── icon.png │ │ ├── manifest.json │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ ├── background.js │ │ └── inject.js │ │ └── webpack.config.mjs ├── nginx.conf ├── openapi │ ├── generate.ts │ └── schemas.json ├── package.json ├── selenium │ ├── driver │ │ ├── LICENSE.chromedriver │ │ ├── THIRD_PARTY_NOTICES.chromedriver │ │ └── chromedriver2 │ └── server │ │ └── selenium-server.jar ├── src │ ├── config.ts │ ├── env.ts │ ├── index.ts │ ├── modules │ │ ├── actions │ │ │ ├── actions.controller.ts │ │ │ ├── actions.routes.ts │ │ │ └── actions.schema.ts │ │ ├── cdp │ │ │ ├── cdp.routes.ts │ │ │ └── cdp.schemas.ts │ │ ├── files │ │ │ ├── files.controller.ts │ │ │ ├── files.routes.ts │ │ │ └── files.schema.ts │ │ ├── selenium │ │ │ ├── selenium.routes.ts │ │ │ └── selenium.schema.ts │ │ └── sessions │ │ │ ├── sessions.controller.ts │ │ │ ├── sessions.routes.ts │ │ │ └── sessions.schema.ts │ ├── plugins │ │ ├── browser-session.ts │ │ ├── browser-socket │ │ │ ├── browser-socket.ts │ │ │ └── casting.handler.ts │ │ ├── browser.ts │ │ ├── custom-body-parser.ts │ │ ├── file-storage.ts │ │ ├── request-logger.ts │ │ ├── scalar-theme.ts │ │ ├── schemas.ts │ │ └── selenium.ts │ ├── routes.ts │ ├── scripts │ │ ├── fingerprint.js │ │ └── index.ts │ ├── services │ │ ├── cdp │ │ │ ├── cdp.service.ts │ │ │ └── plugins │ │ │ │ ├── core │ │ │ │ ├── base-plugin.ts │ │ │ │ ├── index.ts │ │ │ │ └── plugin-manager.ts │ │ │ │ └── pptr-extensions.d.ts │ │ ├── context │ │ │ ├── chrome-context.service.ts │ │ │ └── types.ts │ │ ├── file.service.ts │ │ ├── leveldb │ │ │ ├── localstorage.ts │ │ │ └── sessionstorage.ts │ │ ├── selenium.service.ts │ │ └── session.service.ts │ ├── steel-browser-plugin.ts │ ├── templates │ │ └── live-session-streamer.ejs │ ├── types │ │ ├── browser.ts │ │ ├── casting.ts │ │ ├── enums.ts │ │ ├── fastify.d.ts │ │ └── index.ts │ └── utils │ │ ├── ads.ts │ │ ├── browser.ts │ │ ├── casting.ts │ │ ├── context.ts │ │ ├── errors.ts │ │ ├── extensions.ts │ │ ├── leveldb.ts │ │ ├── logging.ts │ │ ├── proxy.ts │ │ ├── schema.ts │ │ ├── scrape.ts │ │ ├── size.ts │ │ ├── text.ts │ │ └── url.ts ├── tsconfig.json └── tsconfig.test.json ├── commitlint.config.cjs ├── docker-compose.dev.yml ├── docker-compose.yml ├── images ├── demo.gif ├── star_img.png └── steel_header_logo.png ├── nginx.conf ├── package-lock.json ├── package.json ├── render.yaml ├── repl ├── README.md ├── package.json └── src │ └── script.ts └── ui ├── .dockerignore ├── .env.local.example ├── .eslintrc.cjs ├── .gitignore ├── Dockerfile ├── README.md ├── components.json ├── entrypoint.sh ├── index.html ├── nginx.conf.template ├── openapi-ts.config.ts ├── package.json ├── postcss.config.js ├── public ├── grid.svg └── icon.png ├── src ├── App.tsx ├── components │ ├── badges │ │ ├── proxy-badge.tsx │ │ ├── user-agent-badge.tsx │ │ └── websocket-url-badge.tsx │ ├── header │ │ ├── header.tsx │ │ └── index.tsx │ ├── icons │ │ ├── ChromeIcon.tsx │ │ ├── DeleteIcon.tsx │ │ ├── GlobeIcon.tsx │ │ ├── GlowingGreenDot.tsx │ │ ├── KeyIcon.tsx │ │ ├── LoadingSpinner.tsx │ │ ├── NinjaIcon.tsx │ │ ├── SessionIcon.tsx │ │ └── SettingsIcon.tsx │ ├── illustrations │ │ ├── command-line.tsx │ │ └── globe.tsx │ ├── loading │ │ ├── Loading.styles.tsx │ │ ├── Loading.tsx │ │ └── index.tsx │ ├── sessions │ │ ├── release-session-dialog.tsx │ │ ├── session-console │ │ │ ├── index.tsx │ │ │ ├── session-details.tsx │ │ │ ├── session-devtools.tsx │ │ │ └── session-logs.tsx │ │ └── session-viewer │ │ │ ├── empty-state.tsx │ │ │ ├── example-events │ │ │ ├── example-events.json │ │ │ └── test.json │ │ │ ├── index.tsx │ │ │ ├── live-empty-state.tsx │ │ │ ├── session-viewer-controls.css │ │ │ └── session-viewer.tsx │ ├── theme-provider.tsx │ └── ui │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── toast.tsx │ │ └── toaster.tsx ├── containers │ └── session-container.tsx ├── contexts │ └── sessions-context │ │ ├── index.tsx │ │ ├── sessions-context.tsx │ │ └── sessions-context.types.ts ├── env.ts ├── fonts │ ├── Geist │ │ ├── Geist-Black.otf │ │ ├── Geist-Black.woff2 │ │ ├── Geist-Bold.otf │ │ ├── Geist-Bold.woff2 │ │ ├── Geist-Light.otf │ │ ├── Geist-Light.woff2 │ │ ├── Geist-Medium.otf │ │ ├── Geist-Medium.woff2 │ │ ├── Geist-Regular.otf │ │ ├── Geist-Regular.woff2 │ │ ├── Geist-SemiBold.otf │ │ ├── Geist-SemiBold.woff2 │ │ ├── Geist-Thin.otf │ │ ├── Geist-Thin.woff2 │ │ ├── Geist-UltraBlack.otf │ │ ├── Geist-UltraBlack.woff2 │ │ ├── Geist-UltraLight.otf │ │ ├── Geist-UltraLight.woff2 │ │ ├── GeistVariableVF.ttf │ │ ├── GeistVariableVF.woff2 │ │ └── LICENSE.TXT │ └── GeistMono │ │ ├── GeistMono-Black.otf │ │ ├── GeistMono-Black.woff2 │ │ ├── GeistMono-Bold.otf │ │ ├── GeistMono-Bold.woff2 │ │ ├── GeistMono-Light.otf │ │ ├── GeistMono-Light.woff2 │ │ ├── GeistMono-Medium.otf │ │ ├── GeistMono-Medium.woff2 │ │ ├── GeistMono-Regular.otf │ │ ├── GeistMono-Regular.woff2 │ │ ├── GeistMono-SemiBold.otf │ │ ├── GeistMono-SemiBold.woff2 │ │ ├── GeistMono-Thin.otf │ │ ├── GeistMono-Thin.woff2 │ │ ├── GeistMono-UltraBlack.otf │ │ ├── GeistMono-UltraBlack.woff2 │ │ ├── GeistMono-UltraLight.otf │ │ ├── GeistMono-UltraLight.woff2 │ │ ├── GeistMonoVariableVF.ttf │ │ ├── GeistMonoVariableVF.woff2 │ │ └── LICENSE.TXT ├── hooks │ ├── use-sessions-context.ts │ └── use-toast.ts ├── index.css ├── lib │ ├── query-client.ts │ └── utils.ts ├── main.tsx ├── root-layout.tsx ├── steel-client │ ├── index.ts │ ├── schemas.gen.ts │ ├── services.gen.ts │ └── types.gen.ts ├── styles │ ├── common.styles.tsx │ └── theme.ts ├── types │ ├── cdp.ts │ └── props.ts ├── utils │ ├── formatting.ts │ └── toasts.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | build/ 3 | **/.env 4 | **/.env.local 5 | Dockerfile 6 | docker-compose.yml 7 | api/extensions/**/dist 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://HOST:3000 2 | VITE_WS_URL=ws://HOST:3000 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh text eol=lf 3 | *.conf text eol=lf 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] TITLE" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Discord server](https://discord.gg/steel-dev). 11 | 12 | **Describe the bug** 13 | A clear and concise description of what the bug is. 14 | 15 | **To Reproduce** 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Screenshots** 26 | If applicable, add screenshots to help explain your problem. 27 | 28 | **Desktop (please complete the following information):** 29 | - OS: [e.g. iOS] 30 | - Browser [e.g. chrome, safari] 31 | - Version [e.g. 22] 32 | 33 | **Smartphone (please complete the following information):** 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/workflows/build-docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Latest Docker Image to GHCR 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | push_to_registry: 10 | name: Build and Push Latest Docker Image to GHCR 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repo 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Docker Buildx 17 | id: buildx 18 | uses: docker/setup-buildx-action@v3 19 | 20 | - name: Log in to the Container registry 21 | uses: docker/login-action@v3 22 | with: 23 | registry: ghcr.io 24 | username: ${{ secrets.GH_USERNAME }} 25 | password: ${{ secrets.GH_TOKEN }} 26 | 27 | - name: Build and push the latest Steel Browser API image 28 | run: | 29 | docker build -t ghcr.io/steel-dev/steel-browser-api:latest . -f ./api/Dockerfile 30 | docker push ghcr.io/steel-dev/steel-browser-api:latest 31 | - name: Build and push the latest Steel Browser UI image 32 | run: | 33 | docker build -t ghcr.io/steel-dev/steel-browser-ui:latest . -f ./ui/Dockerfile 34 | docker push ghcr.io/steel-dev/steel-browser-ui:latest 35 | -------------------------------------------------------------------------------- /.github/workflows/check-build.yml: -------------------------------------------------------------------------------- 1 | name: Check Docker Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | check-docker-build: 10 | name: Check Docker Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repo 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Docker Buildx 17 | id: buildx 18 | uses: docker/setup-buildx-action@v3 19 | 20 | - name: Build the latest Steel Browser API image 21 | run: | 22 | docker build -t steel-browser-api -f ./api/Dockerfile . 23 | - name: Build the latest Steel Browser UI image 24 | run: | 25 | docker build -t steel-browser-ui -f ./ui/Dockerfile . 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .pnp 3 | .pnp.js 4 | coverage 5 | .DS_Store 6 | *.pem 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | .pnpm-debug.log* 11 | .env 12 | .env.local 13 | .env.development.local 14 | .test.env.local 15 | .env.production.local 16 | !.env.example 17 | production.env 18 | .turbo 19 | build 20 | db/data 21 | *.tsbuildinfo 22 | out 23 | .idea 24 | *.env 25 | dist 26 | .aider* 27 | extensions/* 28 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit "$1" -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | **/.env 4 | **/.env.local 5 | Dockerfile 6 | docker-compose.yml 7 | api/extensions/**/dist 8 | -------------------------------------------------------------------------------- /api/.env.example: -------------------------------------------------------------------------------- 1 | # Server configuration 2 | NODE_ENV=development 3 | HOST=0.0.0.0 4 | PORT=3000 5 | # Use DOMAIN if you want to specify a full domain name instead of HOST:PORT 6 | # DOMAIN=example.com 7 | 8 | # Set to true to use HTTPS/WSS instead of HTTP/WS 9 | USE_SSL=false 10 | 11 | # Chrome/CDP configuration 12 | CHROME_HEADLESS=true 13 | CHROME_EXECUTABLE_PATH= 14 | CHROME_ARGS= 15 | CDP_REDIRECT_PORT=9222 16 | 17 | # Optional proxy configuration 18 | PROXY_URL= 19 | 20 | # Logging 21 | LOG_LEVEL=warn # fatal, error, warn, info, debug, trace 22 | ENABLE_CDP_LOGGING=false 23 | LOG_CUSTOM_EMIT_EVENTS=false 24 | ENABLE_VERBOSE_LOGGING=false 25 | 26 | # Other configuration options 27 | SKIP_FINGERPRINT_INJECTION=false 28 | DEFAULT_TIMEZONE= 29 | DEFAULT_HEADERS= 30 | -------------------------------------------------------------------------------- /api/.gitattributes: -------------------------------------------------------------------------------- 1 | src/scripts/* linguist-vendored 2 | extensions/* linguist-vendored 3 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .pnp 3 | .pnp.js 4 | coverage 5 | .DS_Store 6 | *.pem 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | .pnpm-debug.log* 11 | .env 12 | .env.local 13 | .env.development.local 14 | .test.env.local 15 | .env.production.local 16 | !.env.example 17 | production.env 18 | .turbo 19 | build 20 | db/data 21 | *.tsbuildinfo 22 | out 23 | .idea 24 | *.env 25 | dist 26 | .aider* 27 | extensions/* 28 | !extensions/recorder 29 | files/* 30 | # Ignore future changes to this file 31 | src/services/cdp-lifecycle.service.ts -------------------------------------------------------------------------------- /api/.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /api/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /api/.puppeteerrc.cjs: -------------------------------------------------------------------------------- 1 | const { join } = require("path"); 2 | 3 | /** 4 | * @type {import("puppeteer").Configuration} 5 | */ 6 | module.exports = { 7 | defaultProduct: "chrome", 8 | cacheDirectory: join(__dirname, ".cache", "puppeteer"), 9 | }; 10 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=22.13.0 2 | 3 | FROM node:${NODE_VERSION}-slim AS base 4 | 5 | WORKDIR /app 6 | 7 | ENV NODE_ENV="production" \ 8 | PUPPETEER_CACHE_DIR=/app/.cache \ 9 | DISPLAY=:10 \ 10 | PATH="/usr/bin:/app/selenium/driver:${PATH}" \ 11 | CHROME_BIN=/usr/bin/chromium \ 12 | CHROME_PATH=/usr/bin/chromium 13 | 14 | LABEL org.opencontainers.image.source="https://github.com/steel-dev/steel-browser" 15 | 16 | # Install dependencies 17 | RUN rm -f /etc/apt/apt.conf.d/docker-clean; \ 18 | echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache; \ 19 | apt-get update -qq && \ 20 | DEBIAN_FRONTEND=noninteractive apt-get -yq dist-upgrade 21 | 22 | FROM base AS build 23 | 24 | RUN apt-get update && \ 25 | DEBIAN_FRONTEND=noninteractive apt-get install -y \ 26 | build-essential \ 27 | pkg-config \ 28 | python-is-python3 \ 29 | xvfb 30 | 31 | # Copy root workspace files first 32 | COPY --link package.json package-lock.json ./ 33 | COPY --link api/ ./api/ 34 | 35 | # Install dependencies for api 36 | RUN npm ci --include=dev --workspace=api --ignore-scripts 37 | 38 | # Install dependencies for recorder extension separately 39 | RUN cd api/extensions/recorder && npm ci --include=dev && cd - 40 | 41 | # Build the api package 42 | RUN npm run build -w api 43 | 44 | RUN cd api/extensions/recorder && \ 45 | npm run build && \ 46 | cd - 47 | 48 | # Prune dev dependencies 49 | RUN npm prune --omit=dev -w api 50 | RUN cd api/extensions/recorder && npm prune --omit=dev && cd - 51 | 52 | FROM base AS production 53 | # Install dependencies 54 | RUN apt-get update && \ 55 | DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ 56 | wget \ 57 | nginx \ 58 | gnupg \ 59 | fonts-ipafont-gothic \ 60 | fonts-wqy-zenhei \ 61 | fonts-thai-tlwg \ 62 | fonts-kacst \ 63 | fonts-freefont-ttf \ 64 | libxss1 \ 65 | xvfb \ 66 | curl \ 67 | unzip \ 68 | default-jre \ 69 | dbus \ 70 | dbus-x11 \ 71 | procps \ 72 | x11-xserver-utils 73 | 74 | # Install Chrome and ChromeDriver 75 | RUN apt-get update && \ 76 | DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 77 | wget \ 78 | ca-certificates \ 79 | curl \ 80 | unzip \ 81 | # Download and install Chromium 82 | && apt-get install -y chromium chromium-driver \ 83 | # Clean up 84 | && apt-get clean \ 85 | && rm -rf /var/lib/apt/lists/* \ 86 | && rm -rf /var/cache/apt/* 87 | 88 | RUN mkdir -p /files 89 | 90 | COPY --chmod=755 api/entrypoint.sh /app/api/entrypoint.sh 91 | 92 | EXPOSE 3000 9223 93 | 94 | ENV HOST_IP=localhost \ 95 | DBUS_SESSION_BUS_ADDRESS=autolaunch: 96 | 97 | ENTRYPOINT ["/app/api/entrypoint.sh"] 98 | 99 | COPY --from=build /app /app 100 | -------------------------------------------------------------------------------- /api/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e # Exit on error 3 | 4 | # Function to log with timestamp 5 | log() { 6 | echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" 7 | } 8 | 9 | # Clean up any stale processes and files 10 | cleanup() { 11 | log "Cleaning up stale processes and files..." 12 | if command -v pkill >/dev/null 2>&1; then 13 | pkill chrome || true 14 | pkill dbus-daemon || true 15 | else 16 | kill $(pidof chrome) >/dev/null 2>&1 || true 17 | kill $(pidof dbus-daemon) >/dev/null 2>&1 || true 18 | fi 19 | 20 | rm -f /run/dbus/pid 21 | sleep 1 22 | } 23 | 24 | # Initialize DBus 25 | init_dbus() { 26 | log "Initializing DBus..." 27 | mkdir -p /var/run/dbus 28 | 29 | if [ -e /var/run/dbus/pid ]; then 30 | rm -f /var/run/dbus/pid 31 | fi 32 | 33 | dbus-daemon --system --fork 34 | sleep 2 # Give DBus time to initialize 35 | 36 | if dbus-send --system --print-reply --dest=org.freedesktop.DBus \ 37 | /org/freedesktop/DBus org.freedesktop.DBus.ListNames >/dev/null 2>&1; then 38 | log "DBus initialized successfully" 39 | return 0 40 | else 41 | log "ERROR: DBus failed to initialize" 42 | return 1 43 | fi 44 | } 45 | 46 | # Verify Chrome and ChromeDriver installation 47 | verify_chrome() { 48 | log "Verifying Chrome installation..." 49 | 50 | # Check Chrome binary and version 51 | if [ ! -f "/usr/bin/chromium" ] && [ -z "$CHROME_EXECUTABLE_PATH" ]; then 52 | log "ERROR: Chrome binary not found at /usr/bin/chromium and CHROME_EXECUTABLE_PATH not set" 53 | return 1 54 | fi 55 | 56 | if [ -f "/usr/bin/chromium" ]; then 57 | chrome_version=$(chromium --version 2>/dev/null || echo "unknown") 58 | elif [ -n "$CHROME_EXECUTABLE_PATH" ] && [ -f "$CHROME_EXECUTABLE_PATH" ]; then 59 | chrome_version=$("$CHROME_EXECUTABLE_PATH" --version 2>/dev/null || echo "unknown") 60 | else 61 | chrome_version="unknown" 62 | fi 63 | log "Chrome version: $chrome_version" 64 | 65 | # Check ChromeDriver binary and version 66 | if [ ! -f "/usr/bin/chromedriver" ]; then 67 | log "ERROR: ChromeDriver not found at /usr/bin/chromedriver" 68 | return 1 69 | fi 70 | 71 | chromedriver_version=$(chromedriver --version 2>/dev/null || echo "unknown") 72 | log "ChromeDriver version: $chromedriver_version" 73 | 74 | log "Chrome environment configured successfully" 75 | return 0 76 | } 77 | 78 | # Start nginx with better error handling 79 | start_nginx() { 80 | if [ "$START_NGINX" = "true" ]; then 81 | log "Starting nginx..." 82 | nginx -c /app/api/nginx.conf 83 | 84 | # Wait for nginx to start 85 | max_attempts=10 86 | attempt=1 87 | while [ $attempt -le $max_attempts ]; do 88 | if nginx -t >/dev/null 2>&1; then 89 | log "Nginx started successfully" 90 | return 0 91 | fi 92 | log "Attempt $attempt/$max_attempts: Waiting for nginx..." 93 | attempt=$((attempt + 1)) 94 | sleep 1 95 | done 96 | log "ERROR: Nginx failed to start properly" 97 | return 1 98 | else 99 | log "Skipping nginx startup (--no-nginx flag detected)" 100 | return 0 101 | fi 102 | } 103 | 104 | # Main execution 105 | main() { 106 | # Parse arguments 107 | START_NGINX=true 108 | for arg in "$@"; do 109 | if [ "$arg" = "--no-nginx" ]; then 110 | START_NGINX=false 111 | break 112 | fi 113 | done 114 | 115 | # Initial cleanup 116 | cleanup 117 | 118 | # Initialize services 119 | init_dbus || exit 1 120 | verify_chrome || exit 1 121 | start_nginx || exit 1 122 | 123 | # Set required environment variables 124 | export CDP_REDIRECT_PORT=9223 125 | export HOST=0.0.0.0 126 | export DISPLAY=:10 127 | 128 | # Log environment state 129 | log "Environment configuration:" 130 | log "HOST=$HOST" 131 | log "CDP_REDIRECT_PORT=$CDP_REDIRECT_PORT" 132 | log "NODE_ENV=$NODE_ENV" 133 | 134 | # Start the application 135 | # Run the `npm run start` command but without npm. 136 | # NPM will introduce its own signal handling 137 | # which will prevent the container from waiting 138 | # for a session to be released before stopping gracefully 139 | log "Starting Node.js application..." 140 | exec node ./api/build/index.js 141 | } 142 | 143 | main "$@" -------------------------------------------------------------------------------- /api/extensions/recorder/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /api/extensions/recorder/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steel-dev/steel-browser/a65991be43de0e7d13dc0a91e9db9484455273c4/api/extensions/recorder/icon.png -------------------------------------------------------------------------------- /api/extensions/recorder/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Steel Recording Extension", 4 | "version": "1.0", 5 | "permissions": [ 6 | "scripting", 7 | "activeTab" 8 | ], 9 | "host_permissions": [ 10 | "http://localhost:3000/*", 11 | "http://0.0.0.0:3000/*" 12 | ], 13 | "background": { 14 | "service_worker": "dist/background.js" 15 | }, 16 | "content_scripts": [ 17 | { 18 | "matches": [ 19 | "" 20 | ], 21 | "js": [ 22 | "dist/inject.js" 23 | ] 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /api/extensions/recorder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "steel-recording-extension", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "rrweb": "^2.0.0-alpha.4", 8 | "@rrweb/packer": "^2.0.0-alpha.4" 9 | }, 10 | "scripts": { 11 | "build": "webpack" 12 | }, 13 | "devDependencies": { 14 | "webpack": "^5.89.0", 15 | "webpack-cli": "^5.1.4" 16 | } 17 | } -------------------------------------------------------------------------------- /api/extensions/recorder/src/background.js: -------------------------------------------------------------------------------- 1 | const LOCAL_API_URL = "http://localhost:3000/v1/events"; 2 | const FALLBACK_API_URL = "http://0.0.0.0:3000/v1/events"; // Need to point to 0.0.0.0 in some deploys 3 | let currentApiUrl = LOCAL_API_URL; 4 | 5 | async function injectScript(tabId, changeInfo, tab) { 6 | if (changeInfo.status === "complete" && tab.url) { 7 | try { 8 | await chrome.scripting.executeScript({ 9 | target: { tabId }, 10 | files: ["inject.js"], 11 | }); 12 | } catch (error) { 13 | console.error("Script injection failed:", error); 14 | } 15 | } 16 | } 17 | 18 | // Listen for tab updates 19 | chrome.tabs.onUpdated.addListener(injectScript); 20 | 21 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 22 | if (message.type !== "SAVE_EVENTS") { 23 | return false; 24 | } 25 | 26 | console.log("[Recorder Background] Saving events to", currentApiUrl); 27 | 28 | const sendEvents = async (url) => { 29 | try { 30 | const response = await fetch(url, { 31 | method: "POST", 32 | headers: { 33 | "Content-Type": "application/json", 34 | }, 35 | body: JSON.stringify(message), 36 | }); 37 | 38 | if (!response.ok) { 39 | throw new Error(`HTTP error! status: ${response.status}`); 40 | } 41 | 42 | sendResponse({ success: true }); 43 | } catch (error) { 44 | if (url === LOCAL_API_URL) { 45 | // Retry with fallback URL 46 | currentApiUrl = FALLBACK_API_URL; 47 | return sendEvents(FALLBACK_API_URL); 48 | } 49 | sendResponse({ success: false, error: error.message }); 50 | } 51 | }; 52 | 53 | sendEvents(currentApiUrl); 54 | return true; 55 | }); 56 | -------------------------------------------------------------------------------- /api/extensions/recorder/src/inject.js: -------------------------------------------------------------------------------- 1 | import { record } from "rrweb"; 2 | import { pack } from "@rrweb/packer"; 3 | 4 | record({ 5 | emit: (event) => { 6 | chrome.runtime.sendMessage( 7 | { 8 | type: "SAVE_EVENTS", 9 | events: [event], 10 | }, 11 | (response) => { 12 | if (!response.success) { 13 | console.error("[Recorder] Failed to save events:", response.error); 14 | } 15 | }, 16 | ); 17 | }, 18 | packFn: pack, 19 | sampling: { 20 | media: 800, 21 | }, 22 | inlineImages: true, 23 | collectFonts: true, 24 | recordCrossOriginIframes: true, 25 | recordCanvas: true, 26 | }); 27 | 28 | const enableWebRtcSites = ["meet.google.com", "zoom.us", "discord.com"]; 29 | 30 | try { 31 | const hostname = new URL(window.location.href).hostname; 32 | const shouldDisableWebRtc = !enableWebRtcSites.includes(hostname); 33 | 34 | if (shouldDisableWebRtc) { 35 | navigator.mediaDevices.getUserMedia = 36 | navigator.webkitGetUserMedia = 37 | navigator.mozGetUserMedia = 38 | navigator.getUserMedia = 39 | webkitRTCPeerConnection = 40 | RTCPeerConnection = 41 | MediaStreamTrack = 42 | undefined; 43 | 44 | Object.defineProperty(window, "RTCPeerConnection", { 45 | get: () => { 46 | return {}; 47 | }, 48 | }); 49 | Object.defineProperty(window, "RTCDataChannel", { 50 | get: () => { 51 | return {}; 52 | }, 53 | }); 54 | } 55 | } catch (e) { 56 | console.error(`Error processing URL for WebRTC blocking: ${e}`); 57 | } 58 | -------------------------------------------------------------------------------- /api/extensions/recorder/webpack.config.mjs: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { dirname } from "path"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | export default { 9 | mode: "production", 10 | entry: { 11 | inject: path.resolve(__dirname, "src/inject.js"), 12 | background: path.resolve(__dirname, "src/background.js"), 13 | }, 14 | output: { 15 | filename: "[name].js", 16 | path: path.resolve(__dirname, "dist"), 17 | }, 18 | optimization: { 19 | minimize: false, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /api/nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1024; 3 | } 4 | 5 | http { 6 | server { 7 | listen 9223; 8 | 9 | location / { 10 | proxy_pass http://127.0.0.1:9222; 11 | proxy_http_version 1.1; 12 | proxy_set_header Upgrade $http_upgrade; 13 | proxy_set_header Connection "upgrade"; 14 | proxy_set_header Host $host; 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /api/openapi/generate.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "fs"; 2 | import { server } from "../src"; 3 | import { env } from "../src/env.js"; 4 | 5 | interface OpenAPIServer { 6 | url: string; 7 | description?: string; 8 | } 9 | 10 | interface OpenAPIDocument { 11 | servers?: OpenAPIServer[]; 12 | [key: string]: any; 13 | } 14 | 15 | server.ready(() => { 16 | let openApiJSON = server.swagger() as OpenAPIDocument; 17 | 18 | // Add server URL from environment variables. 19 | const serverUrl = `http://${env.HOST}:${env.PORT}`; 20 | if (!openApiJSON.servers) { 21 | openApiJSON.servers = []; 22 | } 23 | openApiJSON.servers.push({ 24 | url: serverUrl, 25 | description: "Local server from env variables" 26 | }); 27 | 28 | writeFileSync("./openapi/schemas.json", JSON.stringify(openApiJSON, null, 2), "utf-8"); 29 | console.log("OpenAPI JSON has been written to schemas.json"); 30 | 31 | server.close(() => { 32 | console.log("Server closed after generating schemas."); 33 | process.exit(0); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@steel-browser/api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "private": true, 8 | "exports": { 9 | "./plugin": { 10 | "import": { 11 | "types": "./build/steel-browser-plugin.d.ts", 12 | "default": "./build/steel-browser-plugin.js" 13 | } 14 | } 15 | }, 16 | "scripts": { 17 | "start": "node ./build/index.js", 18 | "build": "tsc && npm run copy:templates && npm run copy:fingerprint", 19 | "copy:templates": "mkdir -p build/templates && cp -r src/templates/* build/templates/", 20 | "copy:fingerprint": "cp src/scripts/fingerprint.js build/scripts/fingerprint.js", 21 | "prepare:recorder": "cd extensions/recorder && npm install && npm run build", 22 | "dev": "npm run prepare:recorder && tsx watch src/index.ts", 23 | "test": "echo \"Error: no test specified\" && exit 1", 24 | "pretty": "prettier --write \"src/**/*.ts\"", 25 | "generate:openapi": "ts-node ./openapi/generate.ts" 26 | }, 27 | "author": "Nasr Mohamed", 28 | "devDependencies": { 29 | "@types/archiver": "^6.0.3", 30 | "@types/iconv-lite": "^0.0.1", 31 | "@types/json-stringify-safe": "^5.0.3", 32 | "@types/lodash-es": "^4.17.12", 33 | "@types/mime-types": "^2.1.4", 34 | "@types/node": "^22.14.1", 35 | "@types/ws": "^8.5.14", 36 | "fastify": "^5.2.1", 37 | "prettier": "3.0.3", 38 | "ts-node": "^10.9.2", 39 | "tsx": "^4.19.3", 40 | "typescript": "^5.7.3", 41 | "vite": "^6.3.5" 42 | }, 43 | "dependencies": { 44 | "@fastify/cors": "^10.0.2", 45 | "@fastify/multipart": "^9.0.3", 46 | "@fastify/reply-from": "^12.0.2", 47 | "@fastify/sensible": "^6.0.3", 48 | "@fastify/swagger": "^9.4.2", 49 | "@fastify/view": "10.0.2", 50 | "@fastify/vite": "^7.0.1", 51 | "@mozilla/readability": "^0.5.0", 52 | "@scalar/fastify-api-reference": "^1.25.116", 53 | "archiver": "^7.0.1", 54 | "axios": "^1.8.4", 55 | "chokidar": "^4.0.3", 56 | "dotenv": "^16.4.7", 57 | "ejs": "^3.1.10", 58 | "fastify-plugin": "^5.0.1", 59 | "file-type": "^20.4.1", 60 | "fingerprint-generator": "^2.1.62", 61 | "fingerprint-injector": "^2.1.62", 62 | "http-proxy": "^1.18.1", 63 | "https-proxy-agent": "^7.0.6", 64 | "iconv-lite": "^0.6.3", 65 | "jsdom": "^26.0.0", 66 | "json-stringify-safe": "^5.0.1", 67 | "level": "^9.0.0", 68 | "lodash-es": "^4.17.21", 69 | "mime-types": "^2.1.35", 70 | "pino": "^9.6.0", 71 | "pino-pretty": "^13.0.0", 72 | "proxy-chain": "^2.5.6", 73 | "puppeteer-core": "23.6.0", 74 | "socks-proxy-agent": "^8.0.5", 75 | "turndown": "^7.2.0", 76 | "uuid": "^11.0.5", 77 | "zod": "^3.24.2", 78 | "zod-to-json-schema": "^3.24.1" 79 | }, 80 | "overrides": { 81 | "cross-spawn": "^7.0.6" 82 | }, 83 | "peerDependencies": { 84 | "fastify": "^5.0.0" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /api/selenium/driver/LICENSE.chromedriver: -------------------------------------------------------------------------------- 1 | // Copyright 2015 The Chromium Authors 2 | // 3 | // Redistribution and use in source and binary forms, with or without 4 | // modification, are permitted provided that the following conditions are 5 | // met: 6 | // 7 | // * Redistributions of source code must retain the above copyright 8 | // notice, this list of conditions and the following disclaimer. 9 | // * Redistributions in binary form must reproduce the above 10 | // copyright notice, this list of conditions and the following disclaimer 11 | // in the documentation and/or other materials provided with the 12 | // distribution. 13 | // * Neither the name of Google LLC nor the names of its 14 | // contributors may be used to endorse or promote products derived from 15 | // this software without specific prior written permission. 16 | // 17 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /api/selenium/driver/chromedriver2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steel-dev/steel-browser/a65991be43de0e7d13dc0a91e9db9484455273c4/api/selenium/driver/chromedriver2 -------------------------------------------------------------------------------- /api/selenium/server/selenium-server.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steel-dev/steel-browser/a65991be43de0e7d13dc0a91e9db9484455273c4/api/selenium/server/selenium-server.jar -------------------------------------------------------------------------------- /api/src/config.ts: -------------------------------------------------------------------------------- 1 | import { FastifyServerOptions } from "fastify"; 2 | import { env } from "./env.js"; 3 | import stringify from "json-stringify-safe"; 4 | 5 | interface LoggingConfig { 6 | [key: string]: FastifyServerOptions["logger"]; 7 | } 8 | 9 | export const loggingConfig: LoggingConfig = { 10 | development: { 11 | transport: { 12 | target: "pino-pretty", 13 | options: { 14 | translateTime: "HH:MM:ss Z", 15 | ignore: "pid,hostname", 16 | }, 17 | }, 18 | ...(env.ENABLE_VERBOSE_LOGGING 19 | ? { 20 | hooks: { 21 | logMethod(inputArgs: any[], method: any) { 22 | if (inputArgs.length > 1) { 23 | try { 24 | let resultingMessage = ""; 25 | if (typeof inputArgs[0] === "string") { 26 | const [message, ...args] = inputArgs; 27 | resultingMessage = `${message} ${stringify(args)}`; 28 | } else { 29 | resultingMessage = stringify(inputArgs); 30 | } 31 | return method.apply(this, [resultingMessage]); 32 | } catch (error) { 33 | console.error("Error trying to process logs with verbose logging enabled: ", error); 34 | } 35 | } 36 | return method.apply(this, inputArgs); 37 | }, 38 | }, 39 | } 40 | : {}), 41 | level: process.env.LOG_LEVEL || "debug", 42 | }, 43 | production: {}, 44 | test: false, 45 | }; -------------------------------------------------------------------------------- /api/src/env.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { config } from "dotenv"; 3 | import path from "path"; 4 | 5 | config(); 6 | 7 | const envSchema = z.object({ 8 | NODE_ENV: z.enum(["development", "production"]).default("development"), 9 | HOST: z.string().optional().default("0.0.0.0"), 10 | DOMAIN: z.string().optional(), 11 | PORT: z.string().optional().default("3000"), 12 | USE_SSL: z 13 | .string() 14 | .optional() 15 | .transform((val) => val === "true" || val === "1") 16 | .default("false"), 17 | CDP_REDIRECT_PORT: z.string().optional().default("9222"), 18 | PROXY_URL: z.string().optional(), 19 | DEFAULT_HEADERS: z 20 | .string() 21 | .optional() 22 | .transform((val) => (val ? JSON.parse(val) : {})) 23 | .pipe(z.record(z.string()).optional().default({})), 24 | KILL_TIMEOUT: z.string().optional().default("0"), 25 | CHROME_EXECUTABLE_PATH: z.string().optional(), 26 | CHROME_HEADLESS: z 27 | .string() 28 | .optional() 29 | .transform((val) => val === "true" || val === "1") 30 | .default("true"), 31 | ENABLE_CDP_LOGGING: z 32 | .string() 33 | .optional() 34 | .transform((val) => val === "true" || val === "1") 35 | .default("false"), 36 | LOG_CUSTOM_EMIT_EVENTS: z 37 | .string() 38 | .optional() 39 | .transform((val) => val === "true" || val === "1") 40 | .default("false"), 41 | ENABLE_VERBOSE_LOGGING: z 42 | .string() 43 | .optional() 44 | .transform((val) => val === "true" || val === "1") 45 | .default("false"), 46 | DEFAULT_TIMEZONE: z.string().optional(), 47 | SKIP_FINGERPRINT_INJECTION: z 48 | .string() 49 | .optional() 50 | .transform((val) => val === "true" || val === "1") 51 | .default("false"), 52 | CHROME_ARGS: z 53 | .string() 54 | .optional() 55 | .transform((val) => (val ? val.split(" ").map((arg) => arg.trim()) : [])) 56 | .default(""), 57 | FILTER_CHROME_ARGS: z 58 | .string() 59 | .optional() 60 | .transform((val) => (val ? val.split(" ").map((arg) => arg.trim()) : [])) 61 | .default(""), 62 | DEBUG_CHROME_PROCESS: z 63 | .string() 64 | .optional() 65 | .transform((val) => val === "true" || val === "1") 66 | .default("false"), 67 | }); 68 | 69 | export const env = envSchema.parse(process.env); 70 | -------------------------------------------------------------------------------- /api/src/index.ts: -------------------------------------------------------------------------------- 1 | import fastify from "fastify"; 2 | import fastifyCors from "@fastify/cors"; 3 | import fastifySensible from "@fastify/sensible"; 4 | import steelBrowserPlugin from "./steel-browser-plugin.js"; 5 | import { loggingConfig } from "./config.js"; 6 | import { MB } from "./utils/size.js"; 7 | 8 | const HOST = process.env.HOST ?? "0.0.0.0"; 9 | const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; 10 | 11 | export const server = fastify({ 12 | logger: loggingConfig[process.env.NODE_ENV ?? "development"] ?? true, 13 | trustProxy: true, 14 | bodyLimit: 100 * MB, 15 | disableRequestLogging: true, 16 | }); 17 | 18 | const setupServer = async () => { 19 | await server.register(fastifySensible); 20 | await server.register(fastifyCors, { origin: true }); 21 | await server.register(steelBrowserPlugin, { 22 | fileStorage: { 23 | maxSizePerSession: 100 * MB, 24 | } 25 | }); 26 | }; 27 | 28 | const startServer = async () => { 29 | try { 30 | await setupServer(); 31 | await server.listen({ port: PORT, host: HOST }); 32 | } catch (err) { 33 | server.log.error(err); 34 | process.exit(1); 35 | } 36 | }; 37 | 38 | startServer(); -------------------------------------------------------------------------------- /api/src/modules/actions/actions.routes.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance, FastifyReply } from "fastify"; 2 | import { handlePDF, handleScrape, handleScreenshot } from "./actions.controller.js"; 3 | import { $ref } from "../../plugins/schemas.js"; 4 | import { PDFRequest, ScrapeRequest, ScreenshotRequest } from "./actions.schema.js"; 5 | 6 | async function routes(server: FastifyInstance) { 7 | server.post( 8 | "/scrape", 9 | { 10 | schema: { 11 | operationId: "scrape", 12 | description: "Scrape a URL", 13 | tags: ["Browser Actions"], 14 | summary: "Scrape a URL", 15 | body: $ref("ScrapeRequest"), 16 | response: { 17 | 200: $ref("ScrapeResponse"), 18 | }, 19 | }, 20 | }, 21 | async (request: ScrapeRequest, reply: FastifyReply) => handleScrape(server.sessionService, server.cdpService, request, reply), 22 | ); 23 | 24 | server.post( 25 | "/screenshot", 26 | { 27 | schema: { 28 | operationId: "screenshot", 29 | description: "Take a screenshot", 30 | tags: ["Browser Actions"], 31 | summary: "Take a screenshot", 32 | body: $ref("ScreenshotRequest"), 33 | response: { 34 | 200: $ref("ScreenshotResponse"), 35 | }, 36 | }, 37 | }, 38 | async (request: ScreenshotRequest, reply: FastifyReply) => handleScreenshot(server.sessionService, server.cdpService, request, reply), 39 | ); 40 | 41 | server.post( 42 | "/pdf", 43 | { 44 | schema: { 45 | operationId: "pdf", 46 | description: "Get the PDF content of a page", 47 | tags: ["Browser Actions"], 48 | summary: "Get the PDF content of a page", 49 | body: $ref("PDFRequest"), 50 | response: { 51 | 200: $ref("PDFResponse"), 52 | }, 53 | }, 54 | }, 55 | async (request: PDFRequest, reply: FastifyReply) => handlePDF(server.sessionService, server.cdpService, request, reply), 56 | ); 57 | } 58 | 59 | export default routes; 60 | -------------------------------------------------------------------------------- /api/src/modules/actions/actions.schema.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from "fastify"; 2 | import { z } from "zod"; 3 | import { ScrapeFormat } from "../../types/enums.js"; 4 | 5 | const ScrapeRequest = z.object({ 6 | url: z.string().optional(), 7 | format: z.array(z.nativeEnum(ScrapeFormat)).optional(), 8 | screenshot: z.boolean().optional(), 9 | pdf: z.boolean().optional(), 10 | proxyUrl: z 11 | .string() 12 | .nullable() 13 | .optional() 14 | .describe( 15 | "Proxy URL to use for the scrape. Provide `null` to disable proxy. If not provided, current session proxy settings will be used.", 16 | ), 17 | delay: z.number().optional(), 18 | logUrl: z.string().optional(), 19 | }); 20 | 21 | const ScrapeResponse = z.object({ 22 | content: z.record(z.nativeEnum(ScrapeFormat), z.any()), 23 | metadata: z.object({ 24 | title: z.string().optional(), 25 | ogImage: z.string().optional(), 26 | ogTitle: z.string().optional(), 27 | urlSource: z.string().optional(), 28 | description: z.string().optional(), 29 | ogDescription: z.string().optional(), 30 | statusCode: z.number().int(), 31 | language: z.string().optional(), 32 | timestamp: z.string().datetime().optional(), 33 | published_timestamp: z.string().datetime().optional(), 34 | }), 35 | links: z.array( 36 | z.object({ 37 | url: z.string(), 38 | text: z.string(), 39 | }), 40 | ), 41 | screenshot: z.string().optional(), 42 | pdf: z.string().optional(), 43 | }); 44 | 45 | const ScreenshotRequest = z.object({ 46 | url: z.string().optional(), 47 | proxyUrl: z 48 | .string() 49 | .nullable() 50 | .optional() 51 | .describe( 52 | "Proxy URL to use for the scrape. Provide `null` to disable proxy. If not provided, current session proxy settings will be used.", 53 | ), 54 | delay: z.number().optional(), 55 | fullPage: z.boolean().optional(), 56 | logUrl: z.string().optional(), 57 | }); 58 | 59 | const ScreenshotResponse = z.any(); 60 | 61 | const PDFRequest = z.object({ 62 | url: z.string().optional(), 63 | proxyUrl: z 64 | .string() 65 | .nullable() 66 | .optional() 67 | .describe( 68 | "Proxy URL to use for PDF export. Provide `null` to disable proxy. If not provided, current session proxy settings will be used.", 69 | ), 70 | delay: z.number().optional(), 71 | logUrl: z.string().optional(), 72 | }); 73 | 74 | const PDFResponse = z.any(); 75 | 76 | export type ScrapeRequestBody = z.infer; 77 | export type ScrapeRequest = FastifyRequest<{ Body: ScrapeRequestBody }>; 78 | 79 | export type ScreenshotRequestBody = z.infer; 80 | export type ScreenshotRequest = FastifyRequest<{ Body: ScreenshotRequestBody }>; 81 | 82 | export type PDFRequestBody = z.infer; 83 | export type PDFRequest = FastifyRequest<{ Body: PDFRequestBody }>; 84 | 85 | export const actionsSchemas = { 86 | ScrapeRequest, 87 | ScrapeResponse, 88 | ScreenshotRequest, 89 | ScreenshotResponse, 90 | PDFRequest, 91 | PDFResponse, 92 | }; 93 | 94 | export default actionsSchemas; 95 | -------------------------------------------------------------------------------- /api/src/modules/cdp/cdp.routes.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; 2 | import { z } from "zod"; 3 | import { $ref } from "../../plugins/schemas.js"; 4 | import cdpSchemas from "./cdp.schemas.js"; 5 | 6 | async function routes(server: FastifyInstance) { 7 | server.get( 8 | "/devtools/inspector.html", 9 | { 10 | schema: { 11 | operationId: "getDevtoolsUrl", 12 | description: "Get the URL for the DevTools inspector", 13 | tags: ["CDP"], 14 | summary: "Get the URL for the DevTools inspector", 15 | querystring: $ref("GetDevtoolsUrlSchema"), 16 | }, 17 | }, 18 | async ( 19 | request: FastifyRequest<{ Querystring: z.infer }>, 20 | reply: FastifyReply, 21 | ) => { 22 | return reply.redirect( 23 | `${server.cdpService.getDebuggerUrl()}?ws=${server.cdpService 24 | .getDebuggerWsUrl(request.query.pageId) 25 | .replace("ws:", "")}`, 26 | ); 27 | }, 28 | ); 29 | } 30 | 31 | export default routes; 32 | -------------------------------------------------------------------------------- /api/src/modules/cdp/cdp.schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const GetDevtoolsUrlSchema = z.object({ 4 | pageId: z.string().optional(), 5 | }); 6 | 7 | export default { 8 | GetDevtoolsUrlSchema, 9 | }; 10 | -------------------------------------------------------------------------------- /api/src/modules/files/files.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const FileUploadRequest = z.object({ 4 | file: z.any().describe("The file to upload (binary) or URL string to download from"), 5 | path: z.string().optional().describe("Path to the file in the storage system"), 6 | }); 7 | 8 | const FileDetails = z.object({ 9 | path: z.string().describe("Path to the file in the storage system"), 10 | size: z.number().describe("Size of the file in bytes"), 11 | lastModified: z.string().datetime().describe("Timestamp when the file was last updated"), 12 | }); 13 | 14 | const MultipleFiles = z.object({ 15 | data: z.array(FileDetails).describe("Array of files for the current page"), 16 | }); 17 | 18 | export type FileDetails = z.infer; 19 | export type MultipleFiles = z.infer; 20 | export type FileUploadRequest = z.infer; 21 | 22 | export const filesSchemas = { 23 | FileUploadRequest, 24 | FileDetails, 25 | MultipleFiles, 26 | }; 27 | 28 | export default filesSchemas; 29 | -------------------------------------------------------------------------------- /api/src/modules/selenium/selenium.routes.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; 2 | import fastifyReplyFrom from "@fastify/reply-from"; 3 | 4 | async function routes(server: FastifyInstance) { 5 | server.register(fastifyReplyFrom, { 6 | base: server.seleniumService.getSeleniumServerUrl(), 7 | }); 8 | 9 | server.all("/selenium/wd/*", { schema: { hide: true } }, async (request: FastifyRequest, reply: FastifyReply) => { 10 | if (request.url === "/selenium/wd/session" && request.method === "POST") { 11 | const body = request.body as any; 12 | if (!body.capabilities) { 13 | body.capabilities = {}; 14 | } 15 | if (!body.capabilities.alwaysMatch) { 16 | body.capabilities.alwaysMatch = {}; 17 | } 18 | if (!body.capabilities.alwaysMatch["goog:chromeOptions"]) { 19 | body.capabilities.alwaysMatch["goog:chromeOptions"] = {}; 20 | } 21 | if (!body.capabilities.alwaysMatch["goog:chromeOptions"].args) { 22 | body.capabilities.alwaysMatch["goog:chromeOptions"].args = []; 23 | } 24 | const chromeArgs = await server.seleniumService.getChromeArgs(); 25 | body.capabilities.alwaysMatch["goog:chromeOptions"].args.push(...chromeArgs); 26 | request.body = body; 27 | 28 | return reply.from("/session", { 29 | body, 30 | rewriteHeaders(headers, request) { 31 | headers["content-type"] = "application/json; charset=utf-8"; 32 | headers["accept"] = "application/json; charset=utf-8"; 33 | return headers; 34 | }, 35 | rewriteRequestHeaders(request, headers) { 36 | headers["content-type"] = "application/json; charset=utf-8"; 37 | headers["accept"] = "application/json; charset=utf-8"; 38 | return headers; 39 | }, 40 | }); 41 | } 42 | return reply.from(request.url.replace("/selenium/wd", ""), { 43 | body: request.body, 44 | rewriteRequestHeaders(request, headers) { 45 | headers["content-type"] = "application/json; charset=utf-8"; 46 | headers["accept"] = "application/json; charset=utf-8"; 47 | return headers; 48 | }, 49 | rewriteHeaders(headers, request) { 50 | headers["content-type"] = "application/json; charset=utf-8"; 51 | headers["accept"] = "application/json; charset=utf-8"; 52 | return headers; 53 | }, 54 | }); 55 | }); 56 | } 57 | 58 | export default routes; 59 | -------------------------------------------------------------------------------- /api/src/modules/selenium/selenium.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const LaunchRequest = z.object({ 4 | options: z.object({ 5 | args: z.array(z.string()).optional(), 6 | chromiumSandbox: z.boolean().optional(), 7 | devtools: z.boolean().optional(), 8 | downloadsPath: z.string().optional(), 9 | headless: z.boolean().optional(), 10 | ignoreDefaultArgs: z.union([z.boolean(), z.array(z.string())]).optional(), 11 | proxyUrl: z.string().optional(), 12 | timeout: z.number().optional(), 13 | tracesDir: z.string().optional(), 14 | }), 15 | req: z.any().optional(), 16 | stealth: z.boolean().optional(), 17 | cookies: z.array(z.any()).optional(), 18 | userAgent: z.string().optional(), 19 | extensions: z.array(z.string()).optional(), 20 | logSinkUrl: z.string().optional(), 21 | customHeaders: z.record(z.string()).optional(), 22 | timezone: z.string().optional(), 23 | dimensions: z 24 | .object({ 25 | width: z.number(), 26 | height: z.number(), 27 | }) 28 | .nullable() 29 | .optional(), 30 | }); 31 | 32 | const LaunchResponse = z.object({ 33 | success: z.boolean(), 34 | }); 35 | 36 | export type LaunchRequest = z.infer; 37 | 38 | export const seleniumSchemas = { 39 | LaunchRequest, 40 | LaunchResponse, 41 | }; 42 | 43 | export default seleniumSchemas; 44 | -------------------------------------------------------------------------------- /api/src/plugins/browser-session.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from "fastify"; 2 | import fp from "fastify-plugin"; 3 | import { SessionService } from "../services/session.service.js"; 4 | 5 | const browserSessionPlugin: FastifyPluginAsync = async (fastify, _options) => { 6 | const sessionService = new SessionService({ 7 | cdpService: fastify.cdpService, 8 | seleniumService: fastify.seleniumService, 9 | fileService: fastify.fileService, 10 | logger: fastify.log, 11 | }); 12 | fastify.decorate("sessionService", sessionService); 13 | }; 14 | 15 | export default fp(browserSessionPlugin, "5.x"); 16 | -------------------------------------------------------------------------------- /api/src/plugins/browser-socket/browser-socket.ts: -------------------------------------------------------------------------------- 1 | import { type FastifyInstance, type FastifyPluginAsync } from "fastify"; 2 | import fp from "fastify-plugin"; 3 | import { WebSocketServer, WebSocket } from 'ws'; 4 | import { EmitEvent } from "../../types/enums.js"; 5 | import { handleCastSession } from "./casting.handler.js"; 6 | 7 | // WebSocket server instance 8 | const wss = new WebSocketServer({ noServer: true }); 9 | 10 | // WebSocket handlers 11 | function handleLogsWebSocket(fastify: FastifyInstance, ws: WebSocket) { 12 | const messageHandler = (payload: { pageId: string }) => { 13 | if (ws.readyState === WebSocket.OPEN) { 14 | ws.send(JSON.stringify([payload])); 15 | } 16 | }; 17 | 18 | fastify.cdpService.on(EmitEvent.Log, messageHandler); 19 | 20 | ws.on("error", (err) => { 21 | fastify.log.error("PageId WebSocket error:", err); 22 | }); 23 | 24 | ws.on("close", () => { 25 | fastify.log.info("PageId WebSocket connection closed"); 26 | fastify.cdpService.removeListener(`log`, messageHandler); 27 | }); 28 | } 29 | 30 | const browserWebSocket: FastifyPluginAsync = async (fastify: FastifyInstance, options: any) => { 31 | if (!fastify.cdpService.isRunning()) { 32 | fastify.log.info("Launching browser..."); 33 | await fastify.cdpService.launch(); 34 | fastify.log.info("Browser launched successfully"); 35 | } 36 | 37 | fastify.server.on("upgrade", async (request, socket, head) => { 38 | fastify.log.info("Upgrading browser socket..."); 39 | const url = request.url ?? ""; 40 | const params = Object.fromEntries(new URL(url || "", `http://${request.headers.host}`).searchParams.entries()); 41 | 42 | switch (true) { 43 | case url.startsWith("/v1/sessions/logs"): 44 | fastify.log.info("Connecting to logs..."); 45 | wss.handleUpgrade(request, socket, head, (ws) => handleLogsWebSocket(fastify, ws)); 46 | break; 47 | 48 | case url.startsWith("/v1/sessions/cast"): 49 | fastify.log.info("Connecting to cast..."); 50 | await handleCastSession(request, socket, head, wss, fastify.sessionService, params); 51 | break; 52 | 53 | case url.startsWith("/v1/sessions/pageId"): 54 | fastify.log.info("Connecting to pageId..."); 55 | wss.handleUpgrade(request, socket, head, (ws) => { 56 | const messageHandler = (payload: { pageId: string }) => { 57 | if (ws.readyState === WebSocket.OPEN) { 58 | ws.send(JSON.stringify(payload)); 59 | } 60 | }; 61 | 62 | fastify.cdpService.on(`pageId`, messageHandler); 63 | 64 | ws.on("error", (err) => { 65 | fastify.log.error("PageId WebSocket error:", err); 66 | }); 67 | 68 | ws.on("close", () => { 69 | fastify.log.info("PageId WebSocket connection closed"); 70 | fastify.cdpService.removeListener(`pageId`, messageHandler); 71 | }); 72 | }); 73 | break; 74 | 75 | // Handle recording endpoint 76 | case url.startsWith("/v1/sessions/recording"): 77 | fastify.log.info("Connecting to recording events..."); 78 | wss.handleUpgrade(request, socket, head, (ws) => { 79 | const messageHandler = (payload: { events: Record[] }) => { 80 | if (ws.readyState === WebSocket.OPEN) { 81 | ws.send(JSON.stringify(payload.events)); 82 | } 83 | }; 84 | 85 | fastify.cdpService.on(EmitEvent.Recording, messageHandler); 86 | 87 | // TODO: handle inputs to browser from client 88 | ws.on("message", async (message) => {}); 89 | 90 | ws.on("close", () => { 91 | fastify.log.info("Recording WebSocket connection closed"); 92 | fastify.cdpService.removeListener(EmitEvent.Recording, messageHandler); 93 | }); 94 | 95 | ws.on("error", (err) => { 96 | fastify.log.error("Recording WebSocket error:", err); 97 | }); 98 | }); 99 | break; 100 | 101 | // Default route to CDP Service 102 | default: 103 | fastify.log.info("Connecting to CDP..."); 104 | try { 105 | await fastify.cdpService.proxyWebSocket(request, socket, head); 106 | } catch (err) { 107 | fastify.log.error("CDP WebSocket error:", err); 108 | socket.destroy(); 109 | } 110 | break; 111 | } 112 | }); 113 | }; 114 | 115 | export default fp(browserWebSocket, { name: "browser-websocket" }); 116 | -------------------------------------------------------------------------------- /api/src/plugins/browser.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from "fastify"; 2 | import { CDPService } from "../services/cdp/cdp.service.js"; 3 | import fp from "fastify-plugin"; 4 | import { BrowserLauncherOptions } from "../types/index.js"; 5 | 6 | declare module "fastify" { 7 | interface FastifyInstance { 8 | cdpService: CDPService; 9 | registerCDPLaunchHook: (hook: (config: BrowserLauncherOptions) => Promise | void) => void; 10 | registerCDPShutdownHook: (hook: (config: BrowserLauncherOptions | null) => Promise | void) => void; 11 | } 12 | } 13 | 14 | const browserInstancePlugin: FastifyPluginAsync = async (fastify, _options) => { 15 | const cdpService = new CDPService({}, fastify.log); 16 | 17 | fastify.decorate("cdpService", cdpService); 18 | fastify.decorate("registerCDPLaunchHook", (hook: (config: BrowserLauncherOptions) => Promise | void) => { 19 | cdpService.registerLaunchHook(hook); 20 | }); 21 | fastify.decorate( 22 | "registerCDPShutdownHook", 23 | (hook: (config: BrowserLauncherOptions | null) => Promise | void) => { 24 | cdpService.registerShutdownHook(hook); 25 | }, 26 | ); 27 | 28 | fastify.addHook("onListen", async function () { 29 | this.log.info("Launching default browser..."); 30 | await cdpService.launch(); 31 | }); 32 | }; 33 | 34 | export default fp(browserInstancePlugin, "5.x"); 35 | -------------------------------------------------------------------------------- /api/src/plugins/custom-body-parser.ts: -------------------------------------------------------------------------------- 1 | import fp from "fastify-plugin"; 2 | import { type FastifyInstance, type FastifyPluginAsync } from "fastify"; 3 | 4 | const customBodyParser: FastifyPluginAsync = async (fastify: FastifyInstance) => { 5 | fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, function (req, body, done) { 6 | try { 7 | switch (true) { 8 | case req.url.includes("/release"): 9 | // Skip parsing for release endpoints 10 | done(null, null); 11 | break; 12 | 13 | default: 14 | // Parse JSON for all other requests 15 | done(null, JSON.parse(body.toString())); 16 | } 17 | } catch (error) { 18 | done(error as Error, undefined); 19 | } 20 | }); 21 | }; 22 | 23 | export default fp(customBodyParser, { name: "custom-body-parser" }); 24 | -------------------------------------------------------------------------------- /api/src/plugins/file-storage.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from "fastify"; 2 | import fp from "fastify-plugin"; 3 | import { FileService } from "../services/file.service.js"; 4 | 5 | const fileStoragePlugin: FastifyPluginAsync = async (fastify, _options) => { 6 | fastify.log.info("Registering file service"); 7 | fastify.decorate("fileService", FileService.getInstance()); 8 | }; 9 | 10 | export default fp(fileStoragePlugin, "5.x"); 11 | -------------------------------------------------------------------------------- /api/src/plugins/request-logger.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from "fastify"; 2 | import fp from "fastify-plugin"; 3 | 4 | declare module "fastify" { 5 | interface FastifyReply { 6 | startTime: number; 7 | } 8 | } 9 | 10 | // https://github.com/fastify/fastify/blob/main/lib/logger.js#L67 11 | function now() { 12 | const ts = process.hrtime(); 13 | return ts[0] * 1e3 + ts[1] / 1e6; 14 | } 15 | 16 | const logger: FastifyPluginAsync = async (fastify) => { 17 | fastify.addHook("onRequest", (req, reply, done) => { 18 | reply.startTime = now(); 19 | done(); 20 | }); 21 | 22 | fastify.addHook("onResponse", (req, reply, done) => { 23 | if (req.method !== "OPTIONS" && req.raw.url !== "/status" && req.raw.url !== "/v1/events" && req.raw.url !== "/") { 24 | req.log.info( 25 | { 26 | ip: getClientIp(req), 27 | url: req.raw.url, 28 | method: req.method, 29 | statusCode: reply.raw.statusCode, 30 | durationMs: roundMS(now() - reply.startTime), 31 | }, 32 | "request completed", 33 | ); 34 | } 35 | done(); 36 | }); 37 | }; 38 | 39 | export default fp(logger); 40 | 41 | function roundMS(num: number): number { 42 | return Math.trunc(num * 100) / 100; 43 | } 44 | 45 | function getClientIp(req: any): string { 46 | if (req.headers["x-forwarded-for"]) { 47 | return req.headers["x-forwarded-for"].split(",")[0]; 48 | } 49 | if (req.headers["x-real-ip"]) { 50 | return req.headers["x-real-ip"]; 51 | } 52 | if (req.raw && req.raw.connection) { 53 | return req.raw.connection.remoteAddress || "unknown"; 54 | } 55 | if (req.raw && req.raw.socket) { 56 | return req.raw.socket.remoteAddress || "unknown"; 57 | } 58 | if (req.socket) { 59 | return req.socket.remoteAddress || "unknown"; 60 | } 61 | return "unknown"; 62 | } 63 | -------------------------------------------------------------------------------- /api/src/plugins/schemas.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from "fastify"; 2 | import fp from "fastify-plugin"; 3 | import fastifySwagger from "@fastify/swagger"; 4 | import fastifyScalar from "@scalar/fastify-api-reference"; 5 | import { titleCase } from "../utils/text.js"; 6 | import actionSchemas from "../modules/actions/actions.schema.js"; 7 | import cdpSchemas from "../modules/cdp/cdp.schemas.js"; 8 | import browserSchemas from "../modules/sessions/sessions.schema.js"; 9 | import seleniumSchemas from "../modules/selenium/selenium.schema.js"; 10 | import scalarTheme from "./scalar-theme.js"; 11 | import { buildJsonSchemas } from "../utils/schema.js"; 12 | import filesSchemas from "../modules/files/files.schema.js"; 13 | import { getBaseUrl } from "../utils/url.js"; 14 | 15 | const SCHEMAS = { 16 | ...actionSchemas, 17 | ...browserSchemas, 18 | ...cdpSchemas, 19 | ...seleniumSchemas, 20 | ...filesSchemas, 21 | }; 22 | 23 | export const { schemas, $ref } = buildJsonSchemas(SCHEMAS); 24 | 25 | const schemaPlugin: FastifyPluginAsync = async (fastify) => { 26 | for (const schema of schemas) { 27 | fastify.addSchema(schema); 28 | } 29 | 30 | await fastify.register(fastifySwagger, { 31 | openapi: { 32 | info: { 33 | title: "Steel Browser Instance API", 34 | description: "Documentation for controlling a single instance of Steel Browser", 35 | version: "0.0.1", 36 | }, 37 | servers: [ 38 | { 39 | url: getBaseUrl(), 40 | description: "Local server", 41 | }, 42 | ], 43 | paths: {}, // paths must be included even if it's an empty object 44 | components: { 45 | securitySchemes: {}, 46 | }, 47 | }, 48 | refResolver: { 49 | buildLocalReference: (json, baseUri, fragment, i) => { 50 | return titleCase(json.$id as string) || `Fragment${i}`; 51 | }, 52 | }, 53 | }); 54 | 55 | await fastify.register(fastifyScalar as any, { // scalar still uses fastify v4 56 | routePrefix: "/documentation", 57 | configuration: { 58 | customCss: scalarTheme, 59 | }, 60 | }); 61 | }; 62 | 63 | export default fp(schemaPlugin); 64 | -------------------------------------------------------------------------------- /api/src/plugins/selenium.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from "fastify"; 2 | import fp from "fastify-plugin"; 3 | import { SeleniumService } from "../services/selenium.service.js"; 4 | 5 | const seleniumPlugin: FastifyPluginAsync = async (fastify, options) => { 6 | const seleniumService = new SeleniumService(fastify.log); 7 | fastify.decorate("seleniumService", seleniumService); 8 | }; 9 | 10 | export default fp(seleniumPlugin, "5.x"); 11 | -------------------------------------------------------------------------------- /api/src/routes.ts: -------------------------------------------------------------------------------- 1 | export { default as actionsRoutes } from "./modules/actions/actions.routes.js"; 2 | export { default as sessionsRoutes } from "./modules/sessions/sessions.routes.js"; 3 | export { default as seleniumRoutes } from "./modules/selenium/selenium.routes.js"; 4 | export { default as cdpRoutes } from "./modules/cdp/cdp.routes.js"; 5 | export { default as filesRoutes } from "./modules/files/files.routes.js"; 6 | -------------------------------------------------------------------------------- /api/src/scripts/index.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path, { dirname } from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | const SCRIPTS_DIR = path.join(dirname(fileURLToPath(import.meta.url))); 6 | 7 | export const loadScript = (scriptName: string): string => { 8 | const scriptPath = path.join(SCRIPTS_DIR, scriptName); 9 | return fs.readFileSync(scriptPath, "utf-8"); 10 | }; 11 | 12 | const FIXED_VERSION = "WebGL 1.0 (OpenGL ES 2.0 Chromium)"; 13 | const FIXED_SHADING_LANGUAGE_VERSION = "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)"; 14 | 15 | export const loadFingerprintScript = ({ 16 | fixedVendor, 17 | fixedRenderer, 18 | fixedHardwareConcurrency, 19 | fixedDeviceMemory, 20 | fixedVersion = FIXED_VERSION, 21 | fixedShadingLanguageVersion = FIXED_SHADING_LANGUAGE_VERSION, 22 | }: { 23 | fixedVendor: string; 24 | fixedRenderer: string; 25 | fixedHardwareConcurrency: number; 26 | fixedDeviceMemory: number; 27 | fixedVersion?: string; 28 | fixedShadingLanguageVersion?: string; 29 | }): string => { 30 | const fingerprintScript = loadScript("fingerprint.js"); 31 | 32 | return ` 33 | const FIXED_VENDOR = "${fixedVendor}"; 34 | const FIXED_RENDERER = "${fixedRenderer}"; 35 | const FIXED_VERSION = "${fixedVersion}"; 36 | const FIXED_SHADING_LANGUAGE_VERSION = "${fixedShadingLanguageVersion}"; 37 | const FIXED_HARDWARE_CONCURRENCY = ${fixedHardwareConcurrency}; 38 | const FIXED_DEVICE_MEMORY = ${fixedDeviceMemory}; 39 | ${fingerprintScript} 40 | `; 41 | }; 42 | -------------------------------------------------------------------------------- /api/src/services/cdp/plugins/core/base-plugin.ts: -------------------------------------------------------------------------------- 1 | import { Browser, Page } from "puppeteer-core"; 2 | import { CDPService } from "../../cdp.service.js"; 3 | 4 | export interface PluginOptions { 5 | name: string; 6 | [key: string]: any; 7 | } 8 | 9 | export abstract class BasePlugin { 10 | public name: string; 11 | protected options: PluginOptions; 12 | protected cdpService: CDPService | null; 13 | 14 | constructor(options: PluginOptions) { 15 | this.name = options.name; 16 | this.options = options; 17 | this.cdpService = null; 18 | } 19 | 20 | public setService(service: CDPService): void { 21 | this.cdpService = service; 22 | } 23 | 24 | // Lifecycle methods 25 | public async onBrowserLaunch(browser: Browser): Promise {} 26 | public async onPageCreated(page: Page): Promise {} 27 | public async onPageNavigate(page: Page): Promise {} 28 | public async onPageUnload(page: Page): Promise {} 29 | public async onBrowserClose(browser: Browser): Promise {} 30 | public async onBeforePageClose(page: Page): Promise {} 31 | public async onShutdown(): Promise {} 32 | } 33 | -------------------------------------------------------------------------------- /api/src/services/cdp/plugins/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./base-plugin.js"; 2 | export * from "./plugin-manager.js"; 3 | -------------------------------------------------------------------------------- /api/src/services/cdp/plugins/pptr-extensions.d.ts: -------------------------------------------------------------------------------- 1 | import { SessionManager } from "./session/session-manager.js"; 2 | 3 | declare module "puppeteer-core" { 4 | interface Page { 5 | session: SessionManager; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /api/src/services/context/chrome-context.service.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { FastifyBaseLogger } from "fastify"; 3 | import path from "path"; 4 | import { SessionData } from "./types.js"; 5 | import { ChromeLocalStorageReader } from "../leveldb/localstorage.js"; 6 | import { ChromeSessionStorageReader } from "../leveldb/sessionstorage.js"; 7 | 8 | export class ChromeContextService extends EventEmitter { 9 | private logger: FastifyBaseLogger; 10 | 11 | constructor(logger: FastifyBaseLogger) { 12 | super(); 13 | this.logger = logger; 14 | } 15 | 16 | /** 17 | * Get all session data from a Chrome user data directory 18 | * @param userDataDir Path to Chrome user data directory 19 | * @returns SessionData containing cookies, localStorage, sessionStorage, and more 20 | */ 21 | public async getSessionData(userDataDir?: string): Promise { 22 | if (!userDataDir) { 23 | this.logger.warn("No userDataDir specified, returning empty session data"); 24 | return { 25 | localStorage: {}, 26 | sessionStorage: {}, 27 | indexedDB: {}, 28 | cookies: [], 29 | }; 30 | } 31 | 32 | this.logger.info(`Extracting session data from Chrome user data directory: ${userDataDir}`); 33 | 34 | try { 35 | const sessionData: SessionData = {}; 36 | 37 | const localStorage = await this.extractLocalStorage(userDataDir); 38 | if (localStorage && Object.keys(localStorage).length > 0) { 39 | sessionData.localStorage = localStorage; 40 | } 41 | 42 | const sessionStorage = await this.extractSessionStorage(userDataDir); 43 | if (sessionStorage && Object.keys(sessionStorage).length > 0) { 44 | sessionData.sessionStorage = sessionStorage; 45 | } 46 | 47 | return sessionData; 48 | } catch (error: unknown) { 49 | const errorMessage = error instanceof Error ? error.message : String(error); 50 | this.logger.error(`Error extracting session data: ${errorMessage}`); 51 | throw new Error(`Failed to extract session data: ${errorMessage}`); 52 | } 53 | } 54 | 55 | /** 56 | * Helper to get Chrome profile paths in a cross-platform way 57 | * Takes into account different Chrome profile directory structures 58 | */ 59 | private getProfilePath(userDataDir: string, ...pathSegments: string[]): string { 60 | // Chrome profile directories vary by platform and version 61 | // Both "Default" and "Profile 1" are standard locations 62 | const possibleProfileDirs = ["Default", "Profile 1"]; 63 | 64 | // First check if the userDataDir already includes a profile directory 65 | const dirName = path.basename(userDataDir); 66 | if (possibleProfileDirs.includes(dirName)) { 67 | // userDataDir already points to a profile directory 68 | return path.join(userDataDir, ...pathSegments); 69 | } 70 | 71 | const defaultPath = path.join(userDataDir, "Default", ...pathSegments); 72 | 73 | return defaultPath; 74 | } 75 | 76 | /** 77 | * Extract localStorage from Chrome's LevelDB database 78 | */ 79 | private async extractLocalStorage(userDataDir: string): Promise>> { 80 | const localStoragePath = this.getProfilePath(userDataDir, "Local Storage", "leveldb"); 81 | this.logger.info(`Extracting localStorage from ${localStoragePath}`); 82 | 83 | try { 84 | this.logger.info(`Reading localStorage from ${localStoragePath}`); 85 | return await ChromeLocalStorageReader.readLocalStorage(localStoragePath); 86 | } catch (error: unknown) { 87 | const errorMessage = error instanceof Error ? error.message : String(error); 88 | this.logger.error(`Error extracting localStorage: ${errorMessage}`); 89 | return {}; 90 | } 91 | } 92 | 93 | /** 94 | * Extract sessionStorage from Chrome's Session Storage 95 | */ 96 | private async extractSessionStorage(userDataDir: string): Promise>> { 97 | // Normalize path for cross-platform compatibility 98 | const sessionStoragePath = this.getProfilePath(userDataDir, "Session Storage"); 99 | 100 | try { 101 | this.logger.info(`Reading sessionStorage from ${sessionStoragePath}`); 102 | const sessionStorage = await ChromeSessionStorageReader.readSessionStorage(sessionStoragePath); 103 | return sessionStorage; 104 | } catch (error: unknown) { 105 | const errorMessage = error instanceof Error ? error.message : String(error); 106 | this.logger.error(`Error extracting sessionStorage: ${errorMessage}`); 107 | return {}; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /api/src/services/selenium.service.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { ChildProcess, spawn } from "child_process"; 3 | import { BrowserLauncherOptions, BrowserEvent, BrowserEventType } from "../types/index.js"; 4 | import path, { dirname } from "path"; 5 | import { FastifyBaseLogger } from "fastify"; 6 | import { fileURLToPath } from "url"; 7 | 8 | export class SeleniumService extends EventEmitter { 9 | private seleniumProcess: ChildProcess | null = null; 10 | private seleniumServerUrl: string = "http://localhost:4444"; 11 | private port: number = 4444; 12 | private launchOptions?: BrowserLauncherOptions; 13 | private logger: FastifyBaseLogger; 14 | 15 | constructor(logger: FastifyBaseLogger) { 16 | super(); 17 | this.logger = logger; 18 | } 19 | 20 | public async getChromeArgs(): Promise { 21 | const { options, userAgent } = this.launchOptions ?? {}; 22 | return [ 23 | "disable-dev-shm-usage", 24 | "no-sandbox", 25 | "enable-javascript", 26 | userAgent ? `user-agent=${userAgent}` : "", 27 | options?.proxyUrl ? `proxy-server=${options.proxyUrl}` : "", 28 | ...(options?.args?.map((arg) => (arg.startsWith("--") ? arg.slice(2) : arg)) || []), 29 | ].filter(Boolean); 30 | } 31 | 32 | public async launch(launchOptions: BrowserLauncherOptions): Promise { 33 | this.launchOptions = launchOptions; 34 | 35 | if (this.seleniumProcess) { 36 | await this.close(); 37 | } 38 | 39 | const projectRoot = path.resolve(dirname(fileURLToPath(import.meta.url)), "../../"); 40 | const seleniumServerPath = path.join(projectRoot, "selenium", "server", "selenium-server.jar"); 41 | 42 | const seleniumArgs = ["-jar", seleniumServerPath, "standalone"]; 43 | 44 | this.seleniumProcess = spawn("java", seleniumArgs); 45 | this.seleniumServerUrl = `http://localhost:${this.port}`; 46 | 47 | this.seleniumProcess.stdout?.on("data", (data) => { 48 | this.logger.info(`Selenium stdout: ${data}`); 49 | this.postLog({ 50 | type: BrowserEventType.Console, 51 | text: JSON.stringify({ type: BrowserEventType.Console, message: `${data}` }), 52 | timestamp: new Date(), 53 | }); 54 | }); 55 | 56 | this.seleniumProcess.stderr?.on("data", (data) => { 57 | this.logger.error(`Selenium stderr: ${data}`); 58 | this.postLog({ 59 | type: BrowserEventType.Error, 60 | text: JSON.stringify({ type: BrowserEventType.Error, error: `${data}` }), 61 | timestamp: new Date(), 62 | }); 63 | }); 64 | 65 | this.seleniumProcess.on("close", (code) => { 66 | this.logger.info(`Selenium process exited with code ${code}`); 67 | this.seleniumProcess = null; 68 | }); 69 | 70 | await new Promise((resolve, reject) => { 71 | const timeout = setTimeout(() => { 72 | reject(new Error("Selenium server failed to start within the timeout period")); 73 | }, 15000); // 15 seconds timeout 74 | 75 | this.seleniumProcess!.stdout?.on("data", (data) => { 76 | if (data.toString().includes("Started Selenium Standalone")) { 77 | clearTimeout(timeout); 78 | resolve(); 79 | } 80 | }); 81 | }); 82 | } 83 | 84 | public close(): void { 85 | if (this.seleniumProcess) { 86 | this.seleniumProcess.kill("SIGINT"); 87 | this.seleniumProcess = null; 88 | } 89 | } 90 | 91 | public getSeleniumServerUrl(): string { 92 | return this.seleniumServerUrl; 93 | } 94 | 95 | private async postLog(browserLog: BrowserEvent) { 96 | if (!this.launchOptions?.logSinkUrl) { 97 | return; 98 | } 99 | await fetch(this.launchOptions.logSinkUrl, { 100 | method: "POST", 101 | headers: { 102 | "Content-Type": "application/json", 103 | }, 104 | body: JSON.stringify(browserLog), 105 | }); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /api/src/steel-browser-plugin.ts: -------------------------------------------------------------------------------- 1 | import fastifyView from "@fastify/view"; 2 | import { FastifyPluginAsync } from "fastify"; 3 | import fp from "fastify-plugin"; 4 | import path, { dirname } from "node:path"; 5 | import browserInstancePlugin from "./plugins/browser.js"; 6 | import browserSessionPlugin from "./plugins/browser-session.js"; 7 | import browserWebSocket from "./plugins/browser-socket/browser-socket.js"; 8 | import customBodyParser from "./plugins/custom-body-parser.js"; 9 | import fileStoragePlugin from "./plugins/file-storage.js"; 10 | import requestLogger from "./plugins/request-logger.js"; 11 | import openAPIPlugin from "./plugins/schemas.js"; 12 | import seleniumPlugin from "./plugins/selenium.js"; 13 | import { actionsRoutes, cdpRoutes, filesRoutes, seleniumRoutes, sessionsRoutes } from "./routes.js"; 14 | import { fileURLToPath } from "node:url"; 15 | import ejs from "ejs"; 16 | import type { CDPService } from "./services/cdp/cdp.service.js"; 17 | import type { BrowserLauncherOptions } from "./types/browser.js"; 18 | 19 | // We need to redecalre any decorators from within the plugin that we want to expose 20 | declare module "fastify" { 21 | interface FastifyInstance { 22 | steelBrowserConfig: SteelBrowserConfig; 23 | cdpService: CDPService; 24 | registerCDPLaunchHook: (hook: (config: BrowserLauncherOptions) => Promise | void) => void; 25 | registerCDPShutdownHook: (hook: (config: BrowserLauncherOptions | null) => Promise | void) => void; 26 | } 27 | } 28 | 29 | export interface SteelBrowserConfig { 30 | fileStorage?: { 31 | maxSizePerSession?: number; 32 | }; 33 | } 34 | 35 | const steelBrowserPlugin: FastifyPluginAsync = async (fastify, opts) => { 36 | fastify.decorate("steelBrowserConfig", opts); 37 | // Plugins 38 | await fastify.register(fastifyView, { 39 | engine: { 40 | ejs, 41 | }, 42 | root: path.join(dirname(fileURLToPath(import.meta.url)), "templates"), 43 | }); 44 | await fastify.register(requestLogger); 45 | await fastify.register(openAPIPlugin); 46 | await fastify.register(fileStoragePlugin); 47 | await fastify.register(browserInstancePlugin); 48 | await fastify.register(seleniumPlugin); 49 | await fastify.register(browserWebSocket); 50 | await fastify.register(customBodyParser); 51 | await fastify.register(browserSessionPlugin); 52 | 53 | // Routes 54 | await fastify.register(actionsRoutes, { prefix: "/v1" }); 55 | await fastify.register(sessionsRoutes, { prefix: "/v1" }); 56 | await fastify.register(cdpRoutes, { prefix: "/v1" }); 57 | await fastify.register(seleniumRoutes); 58 | await fastify.register(filesRoutes, { prefix: "/v1" }); 59 | }; 60 | 61 | export default fp(steelBrowserPlugin, { 62 | name: "steel-browser", 63 | fastify: "5.x", 64 | }); 65 | -------------------------------------------------------------------------------- /api/src/types/browser.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserEventType } from "./enums.js"; 2 | import type { CookieData, IndexedDBDatabase, LocalStorageData, SessionStorageData } from "../services/context/types.js"; 3 | import type { CredentialsOptions } from "../modules/sessions/sessions.schema.js"; 4 | 5 | export interface BrowserLauncherOptions { 6 | options: BrowserServerOptions; 7 | req?: Request; 8 | stealth?: boolean; 9 | sessionContext?: { 10 | cookies?: CookieData[]; 11 | localStorage?: Record; 12 | sessionStorage?: Record; 13 | indexedDB?: Record; 14 | }; 15 | userAgent?: string; 16 | extensions?: string[]; 17 | logSinkUrl?: string; 18 | blockAds?: boolean; 19 | customHeaders?: Record; 20 | timezone?: string; 21 | dimensions?: { 22 | width: number; 23 | height: number; 24 | } | null; 25 | userDataDir?: string; 26 | extra?: Record>; 27 | credentials?: CredentialsOptions; 28 | } 29 | 30 | export interface BrowserServerOptions { 31 | args?: string[]; 32 | chromiumSandbox?: boolean; 33 | devtools?: boolean; 34 | downloadsPath?: string; 35 | headless?: boolean; 36 | ignoreDefaultArgs?: boolean | string[]; 37 | proxyUrl?: string; 38 | timeout?: number; 39 | tracesDir?: string; 40 | } 41 | 42 | export type BrowserEvent = { 43 | type: BrowserEventType; 44 | text: string; 45 | timestamp: Date; 46 | }; 47 | -------------------------------------------------------------------------------- /api/src/types/casting.ts: -------------------------------------------------------------------------------- 1 | export type MouseEvent = { 2 | type: "mouseEvent"; 3 | pageId: string; 4 | event: { 5 | type: "mousePressed" | "mouseReleased" | "mouseWheel" | "mouseMoved"; 6 | x: number; 7 | y: number; 8 | button: "none" | "left" | "middle" | "right"; 9 | modifiers: number; 10 | clickCount?: number; 11 | deltaX?: number; 12 | deltaY?: number; 13 | }; 14 | }; 15 | 16 | export type KeyEvent = { 17 | type: "keyEvent"; 18 | pageId: string; 19 | event: { 20 | type: "keyDown" | "keyUp" | "char"; 21 | text?: string; 22 | code: string; 23 | key: string; 24 | keyCode: number; 25 | }; 26 | }; 27 | 28 | export type NavigationEvent = { 29 | type: "navigation"; 30 | pageId: string; 31 | event: { 32 | url?: string; 33 | action?: "back" | "forward" | "refresh"; 34 | }; 35 | }; 36 | 37 | export type CloseTabEvent = { 38 | type: "closeTab"; 39 | pageId: string; 40 | }; 41 | 42 | export type PageInfo = { 43 | id: string; 44 | url: string; 45 | title: string; 46 | favicon: string | null; 47 | }; 48 | -------------------------------------------------------------------------------- /api/src/types/enums.ts: -------------------------------------------------------------------------------- 1 | import { BrowserServerOptions } from "./browser.js"; 2 | 3 | export enum ScrapeFormat { 4 | HTML = "html", 5 | READABILITY = "readability", 6 | CLEANED_HTML = "cleaned_html", 7 | MARKDOWN = "markdown", 8 | } 9 | 10 | export enum BrowserEventType { 11 | Request = "Request", 12 | Navigation = "Navigation", 13 | Console = "Console", 14 | PageError = "PageError", 15 | RequestFailed = "RequestFailed", 16 | Response = "Response", 17 | Error = "Error", 18 | BrowserError = "BrowserError", 19 | Recording = "Recording", 20 | ScreencastFrame = "ScreencastFrame", 21 | } 22 | 23 | export enum EmitEvent { 24 | Log = "log", 25 | PageId = "pageId", 26 | Recording = "recording", 27 | } 28 | -------------------------------------------------------------------------------- /api/src/types/fastify.d.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from "fastify"; 2 | import { CDPService } from "../services/cdp/cdp.service.js"; 3 | import { SessionService } from "../services/session.service.js"; 4 | import { SeleniumService } from "../services/selenium.service.js"; 5 | import { Page } from "puppeteer-core"; 6 | import { FileService } from "../services/file.service.js"; 7 | 8 | declare module "fastify" { 9 | interface FastifyRequest {} 10 | interface FastifyInstance { 11 | seleniumService: SeleniumService; 12 | sessionService: SessionService; 13 | fileService: FileService; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /api/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./enums.js"; 2 | export * from "./browser.js"; 3 | -------------------------------------------------------------------------------- /api/src/utils/ads.ts: -------------------------------------------------------------------------------- 1 | const AD_HOSTS = [ 2 | // Ad Networks & Services 3 | "doubleclick.net", 4 | "adservice.google.com", 5 | "googlesyndication.com", 6 | "google-analytics.com", 7 | "adnxs.com", 8 | "rubiconproject.com", 9 | "advertising.com", 10 | "adtechus.com", 11 | "quantserve.com", 12 | "scorecardresearch.com", 13 | "casalemedia.com", 14 | "moatads.com", 15 | "criteo.com", 16 | "amazon-adsystem.com", 17 | "serving-sys.com", 18 | "adroll.com", 19 | "chartbeat.com", 20 | "sharethrough.com", 21 | "indexww.com", 22 | "mediamath.com", 23 | "adsystem.com", 24 | "adservice.com", 25 | "adnxs.com", 26 | "ads-twitter.com", 27 | 28 | // Analytics & Tracking 29 | "hotjar.com", 30 | "analytics.google.com", 31 | "mixpanel.com", 32 | "kissmetrics.com", 33 | "googletagmanager.com", 34 | 35 | // Ad Exchanges 36 | "openx.net", 37 | "pubmatic.com", 38 | "bidswitch.net", 39 | "taboola.com", 40 | "outbrain.com", 41 | 42 | // Social Media Tracking 43 | "facebook.com/tr/", 44 | "connect.facebook.net", 45 | "platform.twitter.com", 46 | "ads.linkedin.com", 47 | ]; 48 | 49 | export function isAdRequest(url: string): boolean { 50 | try { 51 | const hostname = new URL(url).hostname; 52 | return AD_HOSTS.some((adHost) => hostname === adHost || hostname.endsWith(`.${adHost}`)); 53 | } catch { 54 | return false; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /api/src/utils/browser.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { Page } from "puppeteer-core"; 4 | import { env } from "../env.js"; 5 | 6 | export const getChromeExecutablePath = () => { 7 | if (env.CHROME_EXECUTABLE_PATH) { 8 | const executablePath = env.CHROME_EXECUTABLE_PATH; 9 | const normalizedPath = path.normalize(executablePath); 10 | if (!fs.existsSync(normalizedPath)) { 11 | console.warn(`Your custom chrome executable at ${normalizedPath} does not exist`); 12 | } else { 13 | return executablePath; 14 | } 15 | } 16 | 17 | if (process.platform === "win32") { 18 | const programFilesPath = `${process.env["ProgramFiles"]}\\Google\\Chrome\\Application\\chrome.exe`; 19 | const programFilesX86Path = `C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe`; 20 | 21 | if (fs.existsSync(programFilesPath)) { 22 | return programFilesPath; 23 | } else if (fs.existsSync(programFilesX86Path)) { 24 | return programFilesX86Path; 25 | } 26 | } 27 | 28 | if (process.platform === "darwin") { 29 | return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; 30 | } 31 | 32 | return "/usr/bin/chromium"; 33 | }; 34 | 35 | export async function installMouseHelper(page: Page) { 36 | await page.evaluateOnNewDocument(() => { 37 | // Install mouse helper only for top-level frame. 38 | if (window !== window.parent) return; 39 | window.addEventListener( 40 | "DOMContentLoaded", 41 | () => { 42 | const box = document.createElement("puppeteer-mouse-pointer"); 43 | const styleElement = document.createElement("style"); 44 | styleElement.innerHTML = ` 45 | puppeteer-mouse-pointer { 46 | pointer-events: none; 47 | position: absolute; 48 | top: 0; 49 | z-index: 10000; 50 | left: 0; 51 | width: 20px; 52 | height: 20px; 53 | background: rgba(0,0,0,.4); 54 | border: 1px solid white; 55 | border-radius: 10px; 56 | margin: -10px 0 0 -10px; 57 | padding: 0; 58 | transition: background .2s, border-radius .2s, border-color .2s; 59 | } 60 | puppeteer-mouse-pointer.button-1 { 61 | transition: none; 62 | background: rgba(0,0,0,0.9); 63 | } 64 | puppeteer-mouse-pointer.button-2 { 65 | transition: none; 66 | border-color: rgba(0,0,255,0.9); 67 | } 68 | puppeteer-mouse-pointer.button-3 { 69 | transition: none; 70 | border-radius: 4px; 71 | } 72 | puppeteer-mouse-pointer.button-4 { 73 | transition: none; 74 | border-color: rgba(255,0,0,0.9); 75 | } 76 | puppeteer-mouse-pointer.button-5 { 77 | transition: none; 78 | border-color: rgba(0,255,0,0.9); 79 | } 80 | `; 81 | document.head.appendChild(styleElement); 82 | document.body.appendChild(box); 83 | document.addEventListener( 84 | "mousemove", 85 | (event) => { 86 | box.style.left = event.pageX + "px"; 87 | box.style.top = event.pageY + "px"; 88 | updateButtons(event.buttons); 89 | }, 90 | true, 91 | ); 92 | document.addEventListener( 93 | "mousedown", 94 | (event) => { 95 | updateButtons(event.buttons); 96 | box.classList.add("button-" + event.which); 97 | }, 98 | true, 99 | ); 100 | document.addEventListener( 101 | "mouseup", 102 | (event) => { 103 | updateButtons(event.buttons); 104 | box.classList.remove("button-" + event.which); 105 | }, 106 | true, 107 | ); 108 | function updateButtons(buttons) { 109 | for (let i = 0; i < 5; i++) 110 | // @ts-ignore 111 | box.classList.toggle("button-" + i, buttons & (1 << i)); 112 | } 113 | }, 114 | false, 115 | ); 116 | }); 117 | } 118 | 119 | export function filterHeaders(headers: Record) { 120 | const headersToRemove = [ 121 | "accept-encoding", 122 | "accept", 123 | "cache-control", 124 | "pragma", 125 | "sec-fetch-dest", 126 | "sec-fetch-mode", 127 | "sec-fetch-site", 128 | "sec-fetch-user", 129 | "upgrade-insecure-requests", 130 | ]; 131 | const filteredHeaders = { ...headers }; 132 | headersToRemove.forEach((header) => { 133 | delete filteredHeaders[header]; 134 | }); 135 | return filteredHeaders; 136 | } 137 | -------------------------------------------------------------------------------- /api/src/utils/casting.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "puppeteer-core"; 2 | import { NavigationEvent } from "../types/casting.js"; 3 | 4 | export const navigatePage = async (event: NavigationEvent["event"], targetPage: Page): Promise => { 5 | if (event.action === "back") { 6 | await targetPage.goBack(); 7 | } else if (event.action === "forward") { 8 | await targetPage.goForward(); 9 | } else if (event.action === "refresh") { 10 | await targetPage.reload(); 11 | } else if (event.url) { 12 | const formattedUrl = event.url.startsWith("http") ? event.url : `https://${event.url}`; 13 | 14 | await targetPage.goto(formattedUrl); 15 | } 16 | }; 17 | 18 | export const getPageTitle = async (page: Page): Promise => { 19 | try { 20 | return await page.title(); 21 | } catch (error) { 22 | return "Untitled"; 23 | } 24 | }; 25 | 26 | export const getPageFavicon = async (page: Page): Promise => { 27 | try { 28 | return await page.evaluate(() => { 29 | const iconLink = document.querySelector('link[rel="icon"], link[rel="shortcut icon"]'); 30 | if (iconLink) { 31 | const href = iconLink.getAttribute("href"); 32 | if (href?.startsWith("http")) return href; 33 | if (href?.startsWith("//")) return window.location.protocol + href; 34 | if (href?.startsWith("/")) return window.location.origin + href; 35 | return window.location.origin + "/" + href; 36 | } 37 | return null; 38 | }); 39 | } catch (error) { 40 | return null; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /api/src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | export function getErrors(e: unknown) { 2 | let error: string; 3 | if (typeof e === "string") { 4 | error = e; 5 | } else if (e instanceof Error) { 6 | error = e.message; 7 | } else { 8 | error = "Unknown error"; 9 | } 10 | 11 | return error; 12 | } 13 | -------------------------------------------------------------------------------- /api/src/utils/extensions.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path, { dirname } from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | export function getExtensionPaths(extensionNames: string[]): string[] { 6 | const extensionsDir = path.join(dirname(fileURLToPath(import.meta.url)), "..", "..", "extensions"); 7 | 8 | if (!fs.existsSync(extensionsDir)) { 9 | console.warn("Extensions directory does not exist"); 10 | return []; 11 | } 12 | 13 | const allExtensions = fs.readdirSync(extensionsDir); 14 | 15 | return extensionNames 16 | .filter((name) => allExtensions.includes(name)) 17 | .map((dir) => path.join(extensionsDir, dir)) 18 | .filter((fullPath) => { 19 | if (fs.existsSync(fullPath)) { 20 | return true; 21 | } else { 22 | console.warn(`Extension directory ${fullPath} does not exist`); 23 | return false; 24 | } 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /api/src/utils/leveldb.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs/promises"; 3 | 4 | /** 5 | * Utility to copy a LevelDB directory to a temporary path if opening directly fails (e.g. database lock). 6 | */ 7 | export async function copyDirectory(src: string, dest: string): Promise { 8 | await fs.mkdir(dest, { recursive: true }); 9 | const entries = await fs.readdir(src, { withFileTypes: true }); 10 | await Promise.all( 11 | entries.map(async (entry) => { 12 | const srcPath = path.join(src, entry.name); 13 | const destPath = path.join(dest, entry.name); 14 | if (entry.isDirectory()) { 15 | await copyDirectory(srcPath, destPath); 16 | } else if (entry.isFile()) { 17 | const data = await fs.readFile(srcPath); 18 | await fs.writeFile(destPath, data); 19 | } 20 | }), 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /api/src/utils/logging.ts: -------------------------------------------------------------------------------- 1 | export const updateLog = async (logUrl: string, log: any) => { 2 | try { 3 | const response = await fetch(logUrl, { 4 | method: "PUT", 5 | headers: { 6 | "Content-Type": "application/json", 7 | }, 8 | body: JSON.stringify(log), 9 | }); 10 | if (!response.ok) { 11 | const error = await response.text(); 12 | console.error("Failed to update log", error); 13 | } 14 | } catch (e: unknown) { 15 | console.error(e); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /api/src/utils/proxy.ts: -------------------------------------------------------------------------------- 1 | import { SessionService } from "../services/session.service.js"; 2 | import { Server } from "proxy-chain"; 3 | 4 | export class ProxyServer extends Server { 5 | public url: string; 6 | public upstreamProxyUrl: string; 7 | public txBytes = 0; 8 | public rxBytes = 0; 9 | private hostConnections = new Set(); 10 | 11 | constructor(proxyUrl: string) { 12 | super({ 13 | port: 0, 14 | 15 | prepareRequestFunction: ({ connectionId, hostname }) => { 16 | if (hostname === process.env.HOST) { 17 | this.hostConnections.add(connectionId); 18 | return { 19 | requestAuthentication: false, 20 | upstreamProxyUrl: null, // This will ensure that events sent back to the api are not proxied 21 | }; 22 | } 23 | return { 24 | requestAuthentication: false, 25 | upstreamProxyUrl: proxyUrl, 26 | }; 27 | }, 28 | }); 29 | 30 | this.on("connectionClosed", ({ connectionId, stats }) => { 31 | if (stats && !this.hostConnections.has(connectionId)) { 32 | this.txBytes += stats.trgTxBytes; 33 | this.rxBytes += stats.trgRxBytes; 34 | } 35 | this.hostConnections.delete(connectionId); 36 | }); 37 | 38 | this.url = `http://127.0.0.1:${this.port}`; 39 | this.upstreamProxyUrl = proxyUrl; 40 | } 41 | 42 | async listen(): Promise { 43 | await super.listen(); 44 | this.url = `http://127.0.0.1:${this.port}`; 45 | } 46 | } 47 | 48 | const proxyReclaimRegistry = new FinalizationRegistry((heldValue: Function) => heldValue()); 49 | 50 | export async function createProxyServer(proxyUrl: string): Promise { 51 | const proxy = new ProxyServer(proxyUrl); 52 | await proxy.listen(); 53 | proxyReclaimRegistry.register(proxy, proxy.close); 54 | return proxy; 55 | } 56 | 57 | export async function getProxyServer( 58 | proxyUrl: string | null | undefined, 59 | session: SessionService, 60 | ): Promise { 61 | if (proxyUrl === null) { 62 | return null; 63 | } 64 | 65 | if (proxyUrl === undefined || proxyUrl === session.activeSession.proxyServer?.upstreamProxyUrl) { 66 | return session.activeSession.proxyServer ?? null; 67 | } 68 | 69 | return createProxyServer(proxyUrl); 70 | } 71 | -------------------------------------------------------------------------------- /api/src/utils/schema.ts: -------------------------------------------------------------------------------- 1 | import { ZodType, z } from "zod"; 2 | import zodToJsonSchema from "zod-to-json-schema"; 3 | 4 | export type Models = { 5 | readonly [K in Key]: ZodType; 6 | }; 7 | 8 | export type BuildJsonSchemasOptions = { 9 | readonly $id?: string; 10 | readonly target?: `jsonSchema7` | `openApi3`; 11 | readonly errorMessages?: boolean; 12 | }; 13 | 14 | export type SchemaKey = M extends Models ? Key & string : never; 15 | 16 | export type SchemaKeyOrDescription = 17 | | SchemaKey 18 | | { 19 | readonly description: string; 20 | readonly key: SchemaKey; 21 | }; 22 | 23 | export type $Ref = (key: SchemaKeyOrDescription) => { 24 | readonly $ref: string; 25 | readonly description?: string; 26 | }; 27 | 28 | export type JsonSchema = { 29 | readonly $id: string; 30 | }; 31 | 32 | export type BuildJsonSchemasResult = { 33 | readonly schemas: JsonSchema[]; 34 | readonly $ref: $Ref; 35 | }; 36 | 37 | export const buildJsonSchemas = ( 38 | models: M, 39 | opts: BuildJsonSchemasOptions = {}, 40 | ): BuildJsonSchemasResult => { 41 | const zodSchema = z.object(models); 42 | 43 | const zodJsonSchema = zodToJsonSchema(zodSchema, { 44 | target: "openApi3", 45 | $refStrategy: "none", 46 | errorMessages: opts.errorMessages, 47 | }); 48 | 49 | const cleanedSchemas = Object.entries( 50 | //@ts-ignore 51 | zodJsonSchema.properties as { [key: string]: any }, 52 | ).reduce((acc, [key, value]) => { 53 | return [...acc, { $id: key, title: key, ...value }]; 54 | }, [] as JsonSchema[]); 55 | 56 | const $ref: $Ref = (key) => { 57 | const $ref = `${typeof key === `string` ? key : key.key}#`; 58 | return typeof key === `string` 59 | ? { 60 | $ref, 61 | } 62 | : { 63 | $ref, 64 | description: key.description, 65 | }; 66 | }; 67 | return { 68 | schemas: cleanedSchemas, 69 | $ref, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /api/src/utils/scrape.ts: -------------------------------------------------------------------------------- 1 | import { Readability } from "@mozilla/readability"; 2 | import { JSDOM } from "jsdom"; 3 | import Turndown from "turndown"; 4 | 5 | type ReadabilityResult = { 6 | title: string; 7 | content: string; 8 | textContent: string; 9 | length: number; 10 | excerpt: string; 11 | byline: string; 12 | dir: string; 13 | siteName: string; 14 | lang: string; 15 | publishedTime: string; 16 | }; 17 | 18 | export function getReadabilityContent(htmlString: string): ReadabilityResult | null { 19 | const doc = new JSDOM(htmlString); 20 | const reader = new Readability(doc.window.document); 21 | const articleContent = reader.parse(); 22 | if (!articleContent) return null; 23 | return articleContent; 24 | } 25 | 26 | export const cleanHtml = (html: string): string => { 27 | const blacklistedElements = new Set([ 28 | "head", 29 | "title", 30 | "meta", 31 | "script", 32 | "style", 33 | "path", 34 | "svg", 35 | "br", 36 | "hr", 37 | "link", 38 | "object", 39 | "embed", 40 | ]); 41 | 42 | const blacklistedAttributes = [ 43 | "style", 44 | "ping", 45 | "src", 46 | "item.*", 47 | "aria.*", 48 | "js.*", 49 | "data-.*", 50 | "role", 51 | "tabindex", 52 | "onerror", 53 | ]; 54 | 55 | const dom = new JSDOM(html); 56 | const document = dom.window.document; 57 | 58 | // Remove blacklisted elements 59 | blacklistedElements.forEach((tag) => { 60 | const elements = document.querySelectorAll(tag); 61 | elements.forEach((element) => { 62 | element.remove(); 63 | }); 64 | }); 65 | 66 | // Remove blacklisted attributes 67 | const elements = document.querySelectorAll("*"); 68 | elements.forEach((element) => { 69 | blacklistedAttributes.forEach((attrPattern) => { 70 | const regex = new RegExp(`^${attrPattern}$`); 71 | Array.from(element.attributes).forEach((attr: any) => { 72 | if (regex.test(attr.name)) { 73 | element.removeAttribute(attr.name); 74 | } 75 | }); 76 | }); 77 | }); 78 | 79 | // Remove empty elements 80 | elements.forEach((element) => { 81 | if (!element.hasAttributes() && element.textContent?.trim() === "") { 82 | element.remove(); 83 | } 84 | }); 85 | 86 | const sourceCode = document.documentElement.outerHTML; 87 | 88 | return sourceCode; 89 | }; 90 | 91 | export const getMarkdown = (html: string): string => { 92 | const turndownService = new Turndown(); 93 | const markdown = turndownService.turndown(html); 94 | return markdown; 95 | }; 96 | -------------------------------------------------------------------------------- /api/src/utils/size.ts: -------------------------------------------------------------------------------- 1 | export const KB = 1000; 2 | export const MB = 1000 * KB; // Most proxies use MB, not MiB -------------------------------------------------------------------------------- /api/src/utils/text.ts: -------------------------------------------------------------------------------- 1 | export function titleCase(input: string): string { 2 | if (!input || typeof input !== "string") { 3 | return ""; 4 | } 5 | return input.charAt(0).toUpperCase() + input.slice(1); 6 | } 7 | -------------------------------------------------------------------------------- /api/src/utils/url.ts: -------------------------------------------------------------------------------- 1 | import { env } from "../env.js"; 2 | 3 | /** 4 | * Returns the appropriate protocol based on the protocol type and HTTPS setting 5 | * @param protocolType 'http' or 'ws' - base protocol type 6 | * @returns The protocol string with or without 's' suffix based on env.USE_SSL 7 | */ 8 | function getProtocol(protocolType: "http" | "ws"): string { 9 | return env.USE_SSL ? `${protocolType}s` : protocolType; 10 | } 11 | 12 | /** 13 | * Returns the base URL for the server, handling DOMAIN vs HOST:PORT appropriately 14 | * @param protocolType 'http' or 'ws' - determines the protocol prefix 15 | * @returns Formatted base URL with appropriate protocol and trailing slash 16 | */ 17 | export function getBaseUrl(protocolType: "http" | "ws" = "http"): string { 18 | const baseUrl = env.DOMAIN ?? `${env.HOST}:${env.PORT}`; 19 | const protocol = getProtocol(protocolType); 20 | return `${protocol}://${baseUrl}/`; 21 | } 22 | 23 | /** 24 | * Returns a fully qualified URL with the given path 25 | * @param path The path to append to the base URL 26 | * @param protocolType 'http' or 'ws' - determines the protocol prefix 27 | * @returns Formatted URL with appropriate protocol 28 | */ 29 | export function getUrl(path: string, protocolType: "http" | "ws" = "http"): string { 30 | const base = getBaseUrl(protocolType); 31 | // Handle paths that might already have a leading slash 32 | const formattedPath = path.startsWith("/") ? path.substring(1) : path; 33 | return `${base}${formattedPath}`; 34 | } 35 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "allowSyntheticDefaultImports": true, 5 | "module": "ESNext", 6 | "target": "ESNext", 7 | "moduleResolution": "node", 8 | "skipLibCheck": true, 9 | "sourceMap": true, 10 | "outDir": "build", 11 | "forceConsistentCasingInFileNames": true, 12 | "noImplicitAny": false, 13 | "strict": true, 14 | "declaration": true, 15 | "declarationMap": true 16 | }, 17 | "include": ["src"], 18 | "files": ["./src/types/fastify.d.ts", "src/services/cdp/plugins/pptr-extensions.d.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /api/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "noEmit": true 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | api: 3 | build: 4 | context: . 5 | dockerfile: ./api/Dockerfile 6 | args: 7 | NODE_VERSION: 22.13.0 8 | ports: 9 | - "3000:3000" 10 | - "9223:9223" 11 | volumes: 12 | - ./.cache:/app/.cache 13 | networks: 14 | - steel-network 15 | 16 | ui: 17 | build: 18 | context: . 19 | dockerfile: ./ui/Dockerfile 20 | ports: 21 | - "5173:80" 22 | environment: 23 | - API_URL=${API_URL:-http://api:3000} 24 | depends_on: 25 | - api 26 | networks: 27 | - steel-network 28 | 29 | networks: 30 | steel-network: 31 | name: steel-network 32 | driver: bridge -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | api: 3 | image: ghcr.io/steel-dev/steel-browser-api:latest 4 | ports: 5 | - "3000:3000" 6 | - "9223:9223" 7 | volumes: 8 | - ./.cache:/app/.cache 9 | networks: 10 | - steel-network 11 | 12 | ui: 13 | image: ghcr.io/steel-dev/steel-browser-ui:latest 14 | ports: 15 | - "5173:80" 16 | environment: 17 | - API_URL=${API_URL:-http://api:3000} 18 | depends_on: 19 | - api 20 | networks: 21 | - steel-network 22 | 23 | networks: 24 | steel-network: 25 | name: steel-network 26 | driver: bridge 27 | -------------------------------------------------------------------------------- /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steel-dev/steel-browser/a65991be43de0e7d13dc0a91e9db9484455273c4/images/demo.gif -------------------------------------------------------------------------------- /images/star_img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steel-dev/steel-browser/a65991be43de0e7d13dc0a91e9db9484455273c4/images/star_img.png -------------------------------------------------------------------------------- /images/steel_header_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steel-dev/steel-browser/a65991be43de0e7d13dc0a91e9db9484455273c4/images/steel_header_logo.png -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1024; 3 | } 4 | 5 | http { 6 | server { 7 | listen 9223; 8 | 9 | location / { 10 | proxy_pass http://127.0.0.1:9222; 11 | proxy_http_version 1.1; 12 | proxy_set_header Upgrade $http_upgrade; 13 | proxy_set_header Connection "upgrade"; 14 | proxy_set_header Host $host; 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "steel-browser", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "Apache-2.0", 6 | "author": "", 7 | "type": "module", 8 | "workspaces": [ 9 | "api", 10 | "ui", 11 | "repl" 12 | ], 13 | "scripts": { 14 | "build": "npm run build -w api -w ui", 15 | "dev": "concurrently \"npm run dev -w api\" \"npm run dev -w ui\"", 16 | "prepare": "husky" 17 | }, 18 | "devDependencies": { 19 | "@commitlint/cli": "^19.7.1", 20 | "@commitlint/config-conventional": "^19.7.1", 21 | "@types/archiver": "^6.0.3", 22 | "concurrently": "^8.2.0", 23 | "tsx": "^4.19.2", 24 | "typescript": "^5.7.3" 25 | }, 26 | "engines": { 27 | "node": ">=22" 28 | }, 29 | "dependencies": { 30 | "@types/lodash-es": "^4.17.12", 31 | "fastify": "^5.2.1", 32 | "husky": "^9.1.7", 33 | "lodash-es": "^4.17.21" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: steel-browser 4 | env: docker 5 | dockerfilePath: ./Dockerfile 6 | healthCheckPath: /v1/health 7 | envVars: 8 | - key: NODE_ENV 9 | value: production 10 | disk: 11 | name: cache 12 | mountPath: /app/.cache 13 | sizeGB: 1 -------------------------------------------------------------------------------- /repl/README.md: -------------------------------------------------------------------------------- 1 | # Steel REPL 2 | 3 | This package provides a simple REPL to interact with the browser instance you've created using the API. 4 | 5 | The API exposes a WebSocket endpoint, allowing you to connect to the browser using Chrome DevTools Protocol (CDP) and use Puppeteer as usual. 6 | 7 | ## Quick Start 8 | 9 | 1. Ensure you have **Steel Browser** running, either via Docker or locally. 10 | 2. Run `npm start` to execute the script. 11 | 3. Modify `src/script.ts` as needed and rerun `npm start` to see your changes. 12 | 13 | > Note: You might need to update the WebSocket endpoint in `src/script.ts` if your services isn't exposed on your network 14 | 15 | For more details, refer to [Steel Browser Documentation](https://docs.steel.dev/). -------------------------------------------------------------------------------- /repl/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@steel-browser/repl", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "start": "tsx ./src/script.ts" 8 | }, 9 | "dependencies": { 10 | "puppeteer-core": "^24.2.0" 11 | }, 12 | "devDependencies": { 13 | "tsx": "*" 14 | }, 15 | "engines": { 16 | "node": ">=22.0.0" 17 | } 18 | } -------------------------------------------------------------------------------- /repl/src/script.ts: -------------------------------------------------------------------------------- 1 | import puppeteer from "puppeteer-core"; 2 | 3 | async function run() { 4 | // WebSocket endpoint to connect Browser using Chrome DevTools Protocol (CDP) 5 | const wsEndpoint = "ws://0.0.0.0:3000"; 6 | const browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint }); 7 | 8 | try { 9 | const page = await browser.newPage(); 10 | 11 | // Navigate to a website and log the title 12 | await page.goto("https://steel.dev"); 13 | 14 | console.log(`Page title: ${await page.title()}`); 15 | } finally { 16 | // Cleanup: close all pages and disconnect browser 17 | await Promise.all((await browser.pages()).map((p) => p.close())); 18 | await browser.disconnect(); 19 | } 20 | } 21 | 22 | run().catch(console.error); 23 | -------------------------------------------------------------------------------- /ui/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env.local 3 | .env 4 | -------------------------------------------------------------------------------- /ui/.env.local.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://0.0.0.0:3000 2 | VITE_WS_URL=ws://0.0.0.0:3000 -------------------------------------------------------------------------------- /ui/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "react-refresh/only-export-components": [ 14 | "warn", 15 | { allowConstantExport: true }, 16 | ], 17 | "react-hooks/exhaustive-deps": "off", 18 | "@typescript-eslint/no-explicit-any": "off", 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .env.local 26 | -------------------------------------------------------------------------------- /ui/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=22.13.0 2 | 3 | FROM node:${NODE_VERSION} AS base 4 | 5 | WORKDIR /app 6 | 7 | LABEL org.opencontainers.image.source="https://github.com/steel-dev/steel-browser" 8 | 9 | # Copy package.json and package-lock.json first 10 | COPY --link package.json package-lock.json ./ 11 | COPY --link ui/ ./ui/ 12 | 13 | # Install the npm packages directly in the Docker container's working directory 14 | RUN npm ci --include=dev -w ui --ignore-scripts 15 | 16 | # Build the application 17 | RUN npm run build -w ui 18 | 19 | # Prune dev dependencies 20 | RUN npm prune --omit=dev -w ui 21 | 22 | FROM nginx:alpine 23 | COPY --from=base /app/ui/dist /usr/share/nginx/html 24 | COPY --from=base /app/ui/nginx.conf.template /etc/nginx/nginx.conf.template 25 | COPY --chmod=755 --from=base /app/ui/entrypoint.sh /docker-entrypoint.sh 26 | 27 | EXPOSE 80 28 | 29 | ENTRYPOINT ["/docker-entrypoint.sh"] 30 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: dirname(fileURLToPath(import.meta.url)), 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /ui/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /ui/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | log() { 5 | echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" 6 | } 7 | 8 | substitute_env_vars() { 9 | log "Substituting environment variables in nginx config template..." 10 | sed -e "s|__API_URL__|${API_URL}|g" /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf 11 | } 12 | 13 | main() { 14 | substitute_env_vars 15 | log "Starting nginx..." 16 | exec nginx -g 'daemon off;' 17 | } 18 | 19 | main "$@" 20 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 18 | Steel | Open-source Headless Browser API 19 | 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /ui/nginx.conf.template: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | events { worker_connections 1024; } 3 | 4 | http { 5 | include mime.types; 6 | default_type application/octet-stream; 7 | sendfile on; 8 | 9 | server { 10 | listen 80; 11 | 12 | location /api/ { 13 | proxy_pass __API_URL__/; 14 | proxy_set_header Host $host; 15 | proxy_set_header X-Real-IP $remote_addr; 16 | } 17 | 18 | location /ws/ { 19 | proxy_pass __API_URL__/; 20 | # Required for WebSocket 21 | proxy_http_version 1.1; 22 | proxy_set_header Upgrade $http_upgrade; 23 | proxy_set_header Connection "upgrade"; 24 | proxy_set_header Host $host; 25 | } 26 | 27 | location / { 28 | root /usr/share/nginx/html; 29 | index index.html; 30 | try_files $uri $uri/ /index.html; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ui/openapi-ts.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@hey-api/openapi-ts"; 2 | import dotenv from "dotenv"; 3 | 4 | dotenv.config({ path: ".env.local" }); 5 | 6 | export default defineConfig({ 7 | client: "@hey-api/client-fetch", 8 | input: "../api/openapi/schemas.json", 9 | output: { 10 | format: "prettier", 11 | path: "./src/steel-client", 12 | }, 13 | types: { 14 | dates: "types+transform", 15 | enums: "javascript", 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@steel-browser/ui", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "start": "serve dist -l 5173", 8 | "dev": "vite --host ${HOST:-0.0.0.0}", 9 | "build": "tsc && vite build", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview", 12 | "generate-api": "openapi-ts" 13 | }, 14 | "dependencies": { 15 | "@fontsource/inter": "^5.0.13", 16 | "@hey-api/client-fetch": "^0.4.0", 17 | "@hookform/resolvers": "^3.9.0", 18 | "@radix-ui/react-avatar": "^1.1.1", 19 | "@radix-ui/react-checkbox": "^1.1.2", 20 | "@radix-ui/react-dialog": "^1.1.2", 21 | "@radix-ui/react-icons": "^1.3.0", 22 | "@radix-ui/react-label": "^2.1.0", 23 | "@radix-ui/react-popover": "^1.1.2", 24 | "@radix-ui/react-select": "^2.1.2", 25 | "@radix-ui/react-separator": "^1.1.0", 26 | "@radix-ui/react-slot": "^1.1.0", 27 | "@radix-ui/react-tabs": "^1.1.1", 28 | "@radix-ui/react-toast": "^1.2.2", 29 | "@radix-ui/themes": "^3.0.3", 30 | "@tanstack/react-query": "^4.36.1", 31 | "@tanstack/react-table": "^8.20.5", 32 | "@vitejs/plugin-react": "^4.3.4", 33 | "axios": "^1.3.1", 34 | "class-variance-authority": "^0.7.0", 35 | "clsx": "^2.1.1", 36 | "i": "^0.3.7", 37 | "jwt-decode": "^3.1.2", 38 | "lucide-react": "^0.447.0", 39 | "react": "^18.2.0", 40 | "react-dom": "^18.2.0", 41 | "react-hook-form": "^7.53.0", 42 | "react-router-dom": "^6.16.0", 43 | "react-syntax-highlighter": "^15.5.0", 44 | "react-top-loading-bar": "^2.3.1", 45 | "rrweb-player": "^1.0.0-alpha.4", 46 | "serve": "^14.0.1", 47 | "styled-components": "^6.0.8", 48 | "tailwind-merge": "^2.5.2", 49 | "tailwindcss-animate": "^1.0.7", 50 | "ua-parser-js": "^1.0.39", 51 | "usehooks-ts": "^3.1.0", 52 | "zod": "^3.23.8" 53 | }, 54 | "devDependencies": { 55 | "@hey-api/openapi-ts": "^0.53.6", 56 | "@swc/cli": "^0.6.0", 57 | "@swc/core": "^1.10.18", 58 | "@types/node": "^20.8.4", 59 | "@types/react": "^18.2.15", 60 | "@types/react-dom": "^18.2.7", 61 | "@types/react-syntax-highlighter": "^15.5.8", 62 | "@types/styled-components": "^5.1.28", 63 | "@typescript-eslint/eslint-plugin": "^6.0.0", 64 | "@typescript-eslint/parser": "^6.0.0", 65 | "@vitejs/plugin-react-swc": "^3.3.2", 66 | "autoprefixer": "^10.4.20", 67 | "eslint": "^8.45.0", 68 | "eslint-plugin-react-hooks": "^4.6.0", 69 | "eslint-plugin-react-refresh": "^0.4.3", 70 | "postcss": "^8.4.47", 71 | "tailwindcss": "^3.4.13", 72 | "typescript": "^5.7.3", 73 | "vite": "^6.3.5" 74 | } 75 | } -------------------------------------------------------------------------------- /ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /ui/public/grid.svg: -------------------------------------------------------------------------------- 1 | 8 | 17 | 26 | 35 | 44 | 53 | 62 | 71 | 80 | 89 | 98 | 107 | 116 | 125 | 134 | 143 | 144 | -------------------------------------------------------------------------------- /ui/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steel-dev/steel-browser/a65991be43de0e7d13dc0a91e9db9484455273c4/ui/public/icon.png -------------------------------------------------------------------------------- /ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "@fontsource/inter"; 2 | import "@radix-ui/themes/styles.css"; 3 | import RootLayout from "@/root-layout"; 4 | import { client } from "@/steel-client"; 5 | import { env } from "@/env"; 6 | 7 | client.setConfig({ 8 | baseUrl: env.VITE_API_URL, 9 | }); 10 | 11 | function App() { 12 | return ; 13 | } 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /ui/src/components/badges/proxy-badge.tsx: -------------------------------------------------------------------------------- 1 | import { CopyIcon } from "@radix-ui/react-icons"; 2 | import { Badge } from "@/components/ui/badge"; 3 | import { copyText } from "@/utils/toasts"; 4 | 5 | export function ProxyBadge({ proxy }: { proxy: string }) { 6 | return ( 7 | 11 | {proxy} 12 | copyText(proxy, "Proxy IP")} 17 | /> 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/components/badges/user-agent-badge.tsx: -------------------------------------------------------------------------------- 1 | import { DesktopIcon, CopyIcon } from "@radix-ui/react-icons"; 2 | import { Badge } from "@/components/ui/badge"; 3 | import { ChromeIcon } from "@/components/icons/ChromeIcon"; 4 | import { copyText } from "@/utils/toasts"; 5 | import UAParser from "ua-parser-js"; 6 | export function UserAgentBadge({ userAgent }: { userAgent: string }) { 7 | const parser = new UAParser(userAgent); 8 | 9 | return ( 10 | 14 | {" "} 15 | {parser.getDevice().type || "Desktop"} 16 | {" "} 17 | {`${parser.getBrowser().name} (v${parser.getBrowser().version})`} 18 | copyText(userAgent, "User Agent")} 23 | /> 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/components/badges/websocket-url-badge.tsx: -------------------------------------------------------------------------------- 1 | import { CopyIcon } from "@radix-ui/react-icons"; 2 | import { Badge } from "@/components/ui/badge"; 3 | import { copyText } from "@/utils/toasts"; 4 | 5 | export function WebsocketUrlBadge({ url }: { url: string }) { 6 | return ( 7 | 11 | {url} 12 | copyText(url, "Websocket URL")} 17 | /> 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /ui/src/components/header/header.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronRightIcon } from "@radix-ui/react-icons"; 2 | import { Badge } from "@/components/ui/badge"; 3 | import { GlowingGreenDot } from "@/components/icons/GlowingGreenDot"; 4 | import { useSessionsContext } from "@/hooks/use-sessions-context"; 5 | import { SteelIcon } from "../icons/SessionIcon"; 6 | 7 | export const Header = () => { 8 | const { pathname } = window.location; 9 | const currentSessionId = 10 | pathname.includes("sessions") && pathname.split("/").pop() !== "sessions" 11 | ? pathname.split("/").pop() 12 | : null; 13 | 14 | const { useSession } = useSessionsContext(); 15 | const { data: session, isLoading } = useSession(currentSessionId!); 16 | 17 | return ( 18 |
19 |
20 |
21 |
22 | {currentSessionId ? ( 23 | <> 24 | 25 | Session 26 | 27 | ) : ( 28 | <> 29 | 30 | Session 31 | 32 | )} 33 |
34 | {currentSessionId && ( 35 | <> 36 | 37 |
38 | 39 | #{currentSessionId.split("-")[0]} 40 | 41 | {!isLoading && session?.status === "live" && ( 42 | 46 | 47 | Live 48 | 49 | )} 50 |
51 | 52 | )} 53 |
54 | 72 |
73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /ui/src/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./header"; 2 | -------------------------------------------------------------------------------- /ui/src/components/icons/ChromeIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from "@/types/props"; 2 | 3 | export function ChromeIcon({ 4 | width = 12, 5 | height = 12, 6 | color = "currentColor", 7 | }: IconProps) { 8 | return ( 9 | 16 | 17 | 24 | 31 | 38 | 45 | 52 | 53 | 54 | 55 | 61 | 62 | 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /ui/src/components/icons/DeleteIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from "@/types/props"; 2 | 3 | export function DeleteIcon({ width = 28, height = 28 }: IconProps) { 4 | return ( 5 | 12 | 19 | 26 | 33 | 40 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /ui/src/components/icons/GlobeIcon.tsx: -------------------------------------------------------------------------------- 1 | export const GlobeIcon = () => { 2 | return ( 3 | 10 | 15 | 16 | 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /ui/src/components/icons/GlowingGreenDot.tsx: -------------------------------------------------------------------------------- 1 | export const GlowingGreenDot = () => { 2 | return ( 3 | 4 | 5 | 6 | 7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /ui/src/components/icons/KeyIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from "@/types/props"; 2 | 3 | export function KeyIcon({ 4 | width = 12, 5 | height = 12, 6 | color = "currentColor", 7 | }: IconProps) { 8 | return ( 9 | 16 | 20 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /ui/src/components/icons/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | export interface LoadingSpinnerProps { 4 | className?: string; 5 | } 6 | 7 | export const LoadingSpinner = ({ className }: LoadingSpinnerProps) => { 8 | return ( 9 | 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /ui/src/components/icons/NinjaIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from "@/types/props"; 2 | 3 | export function NinjaIcon({ color = "#A1A1AA" }: IconProps) { 4 | return ( 5 | 12 | 18 | 24 | 30 | 36 | 42 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /ui/src/components/icons/SessionIcon.tsx: -------------------------------------------------------------------------------- 1 | export const SteelIcon = () => { 2 | return ( 3 | 10 | 11 | 12 | 13 | 14 | 22 | 30 | 31 | 39 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /ui/src/components/icons/SettingsIcon.tsx: -------------------------------------------------------------------------------- 1 | export const SettingsIcon = () => { 2 | return ( 3 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /ui/src/components/loading/Loading.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Container = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | justify-content: center; 8 | flex: 1; 9 | `; 10 | -------------------------------------------------------------------------------- /ui/src/components/loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import * as Styled from "./Loading.styles"; 2 | 3 | export function Loading() { 4 | return LOADING...; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/components/loading/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./Loading"; 2 | -------------------------------------------------------------------------------- /ui/src/components/sessions/release-session-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DialogHeader, 3 | DialogFooter, 4 | Dialog, 5 | DialogTrigger, 6 | DialogContent, 7 | DialogTitle, 8 | DialogDescription, 9 | } from "@/components/ui/dialog"; 10 | import { Button } from "@/components/ui/button"; 11 | 12 | import { useSessionsContext } from "@/hooks/use-sessions-context"; 13 | import { useEffect, useState } from "react"; 14 | 15 | export const ReleaseSessionDialog = ({ 16 | children, 17 | id, 18 | }: { 19 | id: string; 20 | children: React.ReactNode; 21 | }) => { 22 | const [open, setOpen] = useState(false); 23 | const { useReleaseSessionMutation } = useSessionsContext(); 24 | 25 | const { 26 | mutate: releaseSession, 27 | isLoading, 28 | isSuccess, 29 | } = useReleaseSessionMutation(); 30 | 31 | useEffect(() => { 32 | if (isSuccess) { 33 | setOpen(false); 34 | } 35 | }, [isSuccess]); 36 | 37 | return ( 38 | 39 | {children} 40 | 41 | 42 | 43 | Are you absolutely sure? 44 | 45 | This action cannot be undone. This will release your session and 46 | delete all your session data. 47 | 48 | 49 | 50 | {!isLoading && ( 51 | 58 | )} 59 | 70 | 71 | 72 | 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /ui/src/components/sessions/session-console/index.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs, TabsTrigger, TabsList } from "@/components/ui/tabs"; 2 | import { useState } from "react"; 3 | import SessionDetails from "./session-details"; 4 | import SessionLogs from "./session-logs"; 5 | import SessionDevTools from "./session-devtools"; 6 | 7 | interface SessionConsoleProps { 8 | id: string | null; 9 | } 10 | 11 | export default function SessionConsole({ id }: SessionConsoleProps) { 12 | const [activeTab, setActiveTab] = useState<"details" | "logs" | "dev-tools">( 13 | "details" 14 | ); 15 | 16 | const tabs: { value: "details" | "logs" | "dev-tools"; label: string }[] = [ 17 | { value: "details", label: "Details" }, 18 | { value: "logs", label: "Logs" }, 19 | { value: "dev-tools", label: "Dev Tools" }, 20 | ]; 21 | 22 | return ( 23 |
24 |
25 | 26 | 27 | {tabs.map((tab) => ( 28 | setActiveTab(tab.value)} 32 | className={`!bg-transparent !box-shadow-none rounded-none p-4 ${ 33 | activeTab === tab.value 34 | ? "border-b-2 border-b-[var(--gray-11)]" 35 | : "" 36 | }`} 37 | > 38 | {tab.label} 39 | 40 | ))} 41 | 42 | 43 |
44 | 45 | {activeTab === "details" && } 46 | {activeTab === "logs" && } 47 | {activeTab === "dev-tools" && } 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /ui/src/components/sessions/session-console/session-devtools.tsx: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import { useEffect, useState } from "react"; 3 | 4 | export default function SessionDevTools() { 5 | const [pageId, setPageId] = useState(null); 6 | 7 | useEffect(() => { 8 | const ws = new WebSocket(`${env.VITE_API_URL}/v1/sessions/pageId`); 9 | 10 | ws.onmessage = (event) => { 11 | setPageId(event.data.pageId); 12 | }; 13 | 14 | return () => { 15 | ws.close(); 16 | }; 17 | }, []); 18 | 19 | useEffect(() => { 20 | if (!pageId) return; 21 | 22 | const iframe = document.querySelector("iframe"); 23 | if (iframe) { 24 | iframe.src = iframe.src + ""; 25 | } 26 | }, [pageId]); 27 | 28 | return ( 29 |