Mirror of github.com/Abhisingh18/PixelStream-BLD
Browse files- .gitignore +5 -0
- README.md +134 -0
- backend/.dockerignore +3 -0
- backend/Dockerfile +34 -0
- backend/entrypoint.sh +6 -0
- backend/package.json +16 -0
- backend/server.js +225 -0
- docker-compose.yml +23 -0
- frontend/.dockerignore +4 -0
- frontend/Dockerfile +12 -0
- frontend/app/layout.js +12 -0
- frontend/app/page.js +226 -0
- frontend/next.config.js +6 -0
- frontend/package.json +15 -0
- render.yaml +16 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
.next/
|
| 3 |
+
npm-debug.log*
|
| 4 |
+
.DS_Store
|
| 5 |
+
*.local
|
README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PixelStream 🖥️➡️🌐
|
| 2 |
+
|
| 3 |
+
A mini "TeamViewer for a browser". You open a web UI, hit **Start Browser**, and a
|
| 4 |
+
**headless Chromium running inside a Docker container** streams its screen back to
|
| 5 |
+
your browser in real time. You can **click, scroll, type, and navigate** — and it
|
| 6 |
+
all happens inside that remote headless browser...........
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## How it works
|
| 12 |
+
|
| 13 |
+
```
|
| 14 |
+
┌──────────────┐ click / scroll / type (WebSocket) ┌───────────────┐ CDP ┌──────────────────┐
|
| 15 |
+
│ Web UI │ ───────────────────────────────────► │ Node backend │ ───────► │ Headless Chromium │
|
| 16 |
+
│ (Next.js) │ │ (ws + CDP) │ │ (Puppeteer) │
|
| 17 |
+
│ <canvas> │ ◄─────────────────────────────────── │ │ ◄─────── │ │
|
| 18 |
+
└──────────────┘ live JPEG frames (WebSocket) └───────────────┘ screencast └──────────────────┘
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
- **Frontend** (`/frontend`) — Next.js app. A `<canvas>` paints the live frames;
|
| 22 |
+
mouse/keyboard/scroll events are captured, converted to real browser
|
| 23 |
+
coordinates, and sent over a WebSocket.
|
| 24 |
+
- **Backend** (`/backend`) — Node.js + `ws` + Puppeteer. Launches Chromium, uses the
|
| 25 |
+
**Chrome DevTools Protocol (CDP)** `Page.startScreencast` to get a continuous
|
| 26 |
+
stream of JPEG frames, and injects input via `Input.dispatchMouseEvent` /
|
| 27 |
+
`Input.dispatchKeyEvent`.
|
| 28 |
+
- **Docker** — both services run in containers via `docker compose`. The backend
|
| 29 |
+
uses the official Puppeteer image (Chromium + system deps preinstalled).
|
| 30 |
+
|
| 31 |
+
### Why CDP screencast (not a screenshot loop)?
|
| 32 |
+
|
| 33 |
+
`Page.startScreencast` only emits a frame when the page actually changes, and it's
|
| 34 |
+
much smoother and lower-latency than taking `page.screenshot()` on a timer.
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## Run it
|
| 39 |
+
|
| 40 |
+
### Option A — Docker (recommended)
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
docker compose up --build
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
Then open **http://localhost:3000** and click **Start Browser**.
|
| 47 |
+
|
| 48 |
+
### Option B — Local (no Docker, for fast iteration)
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
# terminal 1
|
| 52 |
+
cd backend && npm install && npm start # ws server on :8080
|
| 53 |
+
|
| 54 |
+
# terminal 2
|
| 55 |
+
cd frontend && npm install && npm run dev # UI on :3000
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## Deploy (Frontend → Vercel, Backend → Render)
|
| 61 |
+
|
| 62 |
+
> ⚠️ The browser page is served over **HTTPS** (Vercel), so it can only open a
|
| 63 |
+
> **secure** WebSocket (`wss://`). Render gives you an `https://…onrender.com`
|
| 64 |
+
> URL, whose WebSocket endpoint is `wss://…onrender.com`. Plain `ws://` will be
|
| 65 |
+
> blocked as mixed content.
|
| 66 |
+
|
| 67 |
+
### 1. Backend on Render
|
| 68 |
+
|
| 69 |
+
- New **Web Service** → connect this repo.
|
| 70 |
+
- Runtime: **Docker**, Dockerfile path: `backend/Dockerfile`, context: `backend`
|
| 71 |
+
(or just use the included `render.yaml` Blueprint).
|
| 72 |
+
- Deploy. Note the URL, e.g. `https://pixelstream-backend.onrender.com`.
|
| 73 |
+
- Render injects `PORT` automatically — the server already reads it.
|
| 74 |
+
|
| 75 |
+
### 2. Frontend on Vercel
|
| 76 |
+
|
| 77 |
+
- New Project → import this repo → set **Root Directory = `frontend`**.
|
| 78 |
+
- Add an environment variable:
|
| 79 |
+
|
| 80 |
+
| Key | Value |
|
| 81 |
+
|-----|-------|
|
| 82 |
+
| `NEXT_PUBLIC_WS_URL` | `wss://pixelstream-backend.onrender.com` |
|
| 83 |
+
|
| 84 |
+
- Deploy. Open the Vercel URL → **Start Browser**.
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
---
|
| 88 |
+
|
| 89 |
+
## What works ✅
|
| 90 |
+
|
| 91 |
+
- Start a headless Chromium in a Docker container from the web UI
|
| 92 |
+
- Live screen streaming via CDP screencast (JPEG frames over WebSocket)
|
| 93 |
+
- Mouse: move, left/right click, scroll
|
| 94 |
+
- Keyboard: printable characters + common special keys (Enter, Backspace, Tab,
|
| 95 |
+
arrows, Esc, Delete…)
|
| 96 |
+
- URL bar to navigate the remote browser
|
| 97 |
+
- Accurate click mapping (canvas coords → real viewport coords)
|
| 98 |
+
|
| 99 |
+
## Known limitations / where it gets hard ⚠️
|
| 100 |
+
|
| 101 |
+
- **Keyboard coverage** — only common special keys are mapped; modifier
|
| 102 |
+
combos (Ctrl+C, Shift+selection) aren't fully wired yet.
|
| 103 |
+
- **Single session** — one browser per server; no multi-user / multi-tab.
|
| 104 |
+
- **Streaming** — JPEG-over-WebSocket is simple but bandwidth-heavy; WebRTC or
|
| 105 |
+
VP8/H.264 encoding would be smoother at scale.
|
| 106 |
+
- **Chromium-in-Docker** — needs `--no-sandbox`, `--disable-dev-shm-usage`, and
|
| 107 |
+
`shm_size: 1gb` or Chromium crashes. (These are already set.)
|
| 108 |
+
|
| 109 |
+
## Next steps 🚀
|
| 110 |
+
|
| 111 |
+
1. Full keyboard model with modifiers + IME/composition events
|
| 112 |
+
2. Multiple isolated sessions (one container per user, spawned on demand)
|
| 113 |
+
3. WebRTC streaming for lower latency / adaptive quality
|
| 114 |
+
4. Reconnect handling + session timeouts + resource cleanup
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## Project structure
|
| 119 |
+
|
| 120 |
+
```
|
| 121 |
+
Pixel/
|
| 122 |
+
├── docker-compose.yml
|
| 123 |
+
├── backend/
|
| 124 |
+
│ ├── Dockerfile
|
| 125 |
+
│ ├── package.json
|
| 126 |
+
│ └── server.js # ws server + Puppeteer + CDP screencast/input
|
| 127 |
+
└── frontend/
|
| 128 |
+
├── Dockerfile
|
| 129 |
+
├── package.json
|
| 130 |
+
├── next.config.js
|
| 131 |
+
└── app/
|
| 132 |
+
├── layout.js
|
| 133 |
+
└── page.js # UI + canvas + input capture
|
| 134 |
+
```
|
backend/.dockerignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
npm-debug.log
|
| 3 |
+
.git
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PixelStream backend — uses the official Puppeteer image which ships Chromium
|
| 2 |
+
# and all its system dependencies preinstalled (saves a lot of pain).
|
| 3 |
+
FROM ghcr.io/puppeteer/puppeteer:22.15.0
|
| 4 |
+
|
| 5 |
+
USER root
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# Install: (1) Xvfb = virtual display so Chrome runs HEADFUL (big anti-bot win),
|
| 9 |
+
# and (2) REAL Google Chrome stable — not just Chromium — because Chrome ships
|
| 10 |
+
# proprietary codecs and Chrome-only signals that reCAPTCHA looks for.
|
| 11 |
+
# The base image has an expired Google apt key that breaks `apt-get update`, so
|
| 12 |
+
# we drop that repo first; we install Chrome straight from the official .deb.
|
| 13 |
+
RUN rm -f /etc/apt/sources.list.d/google*.list \
|
| 14 |
+
&& apt-get update \
|
| 15 |
+
&& apt-get install -y --no-install-recommends xvfb xauth wget ca-certificates \
|
| 16 |
+
&& wget -q -O /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
|
| 17 |
+
&& (dpkg -i /tmp/chrome.deb || apt-get install -y -f --no-install-recommends) \
|
| 18 |
+
&& rm -f /tmp/chrome.deb \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
+
|
| 21 |
+
# Install deps first (better layer caching)
|
| 22 |
+
COPY package.json ./
|
| 23 |
+
RUN npm install --omit=dev
|
| 24 |
+
|
| 25 |
+
# App code
|
| 26 |
+
COPY . .
|
| 27 |
+
|
| 28 |
+
# Use the real Google Chrome we just installed (not the bundled Chromium).
|
| 29 |
+
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
| 30 |
+
ENV CHROME_PATH=/usr/bin/google-chrome-stable
|
| 31 |
+
|
| 32 |
+
EXPOSE 8080
|
| 33 |
+
# Start Xvfb + node via entrypoint so Chrome runs headful and logs are visible.
|
| 34 |
+
CMD ["sh", "/app/entrypoint.sh"]
|
backend/entrypoint.sh
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
# Start a virtual display, then run the app in the foreground so Chrome can
|
| 3 |
+
# launch headful and node's logs go straight to the container's stdout.
|
| 4 |
+
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp &
|
| 5 |
+
export DISPLAY=:99
|
| 6 |
+
exec node server.js
|
backend/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "pixelstream-backend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "PixelStream backend — headless Chromium control over WebSocket using Puppeteer + CDP screencast",
|
| 5 |
+
"main": "server.js",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"start": "node server.js",
|
| 8 |
+
"dev": "node server.js"
|
| 9 |
+
},
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"puppeteer": "^22.15.0",
|
| 12 |
+
"puppeteer-extra": "^3.3.6",
|
| 13 |
+
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
| 14 |
+
"ws": "^8.18.0"
|
| 15 |
+
}
|
| 16 |
+
}
|
backend/server.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// PixelStream backend
|
| 2 |
+
// ----------------------------------------------------------------------------
|
| 3 |
+
// Flow: Frontend <--WebSocket--> this server <--CDP--> headless Chromium
|
| 4 |
+
//
|
| 5 |
+
// - "start" : launch Chromium, begin CDP screencast, stream JPEG frames out
|
| 6 |
+
// - mouse/keyboard/scroll events come in, get injected via CDP Input.* events
|
| 7 |
+
// - "navigate": load a new URL in the page
|
| 8 |
+
// ----------------------------------------------------------------------------
|
| 9 |
+
|
| 10 |
+
const http = require("http");
|
| 11 |
+
const { WebSocketServer } = require("ws");
|
| 12 |
+
const puppeteer = require("puppeteer-extra");
|
| 13 |
+
const StealthPlugin = require("puppeteer-extra-plugin-stealth");
|
| 14 |
+
|
| 15 |
+
// Stealth plugin masks ~20 different headless/automation fingerprints that
|
| 16 |
+
// sites like Google use to detect bots. Far stronger than manual masking.
|
| 17 |
+
puppeteer.use(StealthPlugin());
|
| 18 |
+
|
| 19 |
+
const PORT = process.env.PORT || 8080;
|
| 20 |
+
const VIEWPORT = { width: 1280, height: 720 };
|
| 21 |
+
// Default to Google. With real Chrome + headful (Xvfb) + stealth + your local
|
| 22 |
+
// residential IP (Docker NATs through the host network), reCAPTCHA should now
|
| 23 |
+
// accept manual solves. Override with START_URL if you want another landing page.
|
| 24 |
+
const START_URL = process.env.START_URL || "https://www.google.com";
|
| 25 |
+
|
| 26 |
+
const USER_AGENT =
|
| 27 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
| 28 |
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
| 29 |
+
|
| 30 |
+
// Map of special (non-printable) keys -> Windows virtual key codes.
|
| 31 |
+
// CDP needs windowsVirtualKeyCode for keys like Enter, Backspace, arrows, etc.
|
| 32 |
+
const KEY_CODES = {
|
| 33 |
+
Backspace: 8,
|
| 34 |
+
Tab: 9,
|
| 35 |
+
Enter: 13,
|
| 36 |
+
Shift: 16,
|
| 37 |
+
Control: 17,
|
| 38 |
+
Alt: 18,
|
| 39 |
+
Escape: 27,
|
| 40 |
+
" ": 32,
|
| 41 |
+
PageUp: 33,
|
| 42 |
+
PageDown: 34,
|
| 43 |
+
End: 35,
|
| 44 |
+
Home: 36,
|
| 45 |
+
ArrowLeft: 37,
|
| 46 |
+
ArrowUp: 38,
|
| 47 |
+
ArrowRight: 39,
|
| 48 |
+
ArrowDown: 40,
|
| 49 |
+
Delete: 46,
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const server = http.createServer((req, res) => {
|
| 53 |
+
// simple health check
|
| 54 |
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
| 55 |
+
res.end("PixelStream backend running\n");
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
const wss = new WebSocketServer({ server });
|
| 59 |
+
|
| 60 |
+
wss.on("connection", (ws) => {
|
| 61 |
+
console.log("[ws] client connected");
|
| 62 |
+
|
| 63 |
+
let browser = null;
|
| 64 |
+
let page = null;
|
| 65 |
+
let client = null; // CDP session
|
| 66 |
+
|
| 67 |
+
const send = (obj) => {
|
| 68 |
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const cleanup = async () => {
|
| 72 |
+
try {
|
| 73 |
+
if (browser) await browser.close();
|
| 74 |
+
} catch (_) {}
|
| 75 |
+
browser = page = client = null;
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
ws.on("message", async (raw) => {
|
| 79 |
+
let msg;
|
| 80 |
+
try {
|
| 81 |
+
msg = JSON.parse(raw.toString());
|
| 82 |
+
} catch {
|
| 83 |
+
return;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
try {
|
| 87 |
+
// ---------------------------------------------------------------- START
|
| 88 |
+
if (msg.type === "start") {
|
| 89 |
+
if (browser) return; // already running
|
| 90 |
+
console.log("[browser] launching...");
|
| 91 |
+
|
| 92 |
+
browser = await puppeteer.launch({
|
| 93 |
+
headless: false, // headful under Xvfb -> much higher reCAPTCHA trust
|
| 94 |
+
executablePath: process.env.CHROME_PATH || undefined,
|
| 95 |
+
defaultViewport: VIEWPORT,
|
| 96 |
+
userDataDir: "/app/user-data", // persist cookies/profile across sessions
|
| 97 |
+
args: [
|
| 98 |
+
"--no-sandbox",
|
| 99 |
+
"--disable-setuid-sandbox",
|
| 100 |
+
"--disable-dev-shm-usage",
|
| 101 |
+
"--disable-gpu",
|
| 102 |
+
"--disable-blink-features=AutomationControlled", // hide automation flag
|
| 103 |
+
"--lang=en-US,en",
|
| 104 |
+
`--window-size=${VIEWPORT.width},${VIEWPORT.height}`,
|
| 105 |
+
],
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
page = await browser.newPage();
|
| 109 |
+
await page.setViewport(VIEWPORT);
|
| 110 |
+
|
| 111 |
+
// --- make the headless browser look like a real one (less bot-detection) ---
|
| 112 |
+
await page.setUserAgent(USER_AGENT);
|
| 113 |
+
await page.setExtraHTTPHeaders({ "Accept-Language": "en-US,en;q=0.9" });
|
| 114 |
+
await page.evaluateOnNewDocument(() => {
|
| 115 |
+
// hide the webdriver flag that automation tools expose
|
| 116 |
+
Object.defineProperty(navigator, "webdriver", { get: () => undefined });
|
| 117 |
+
// pretend to have plugins + languages like a normal Chrome
|
| 118 |
+
Object.defineProperty(navigator, "languages", { get: () => ["en-US", "en"] });
|
| 119 |
+
Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3, 4, 5] });
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
await page.goto(START_URL, { waitUntil: "domcontentloaded" }).catch(() => {});
|
| 123 |
+
|
| 124 |
+
// CDP session: handles both screencast (out) and input (in)
|
| 125 |
+
client = await page.target().createCDPSession();
|
| 126 |
+
|
| 127 |
+
client.on("Page.screencastFrame", async ({ data, sessionId }) => {
|
| 128 |
+
send({ type: "frame", data }); // base64-encoded JPEG
|
| 129 |
+
try {
|
| 130 |
+
await client.send("Page.screencastFrameAck", { sessionId });
|
| 131 |
+
} catch (_) {}
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
await client.send("Page.startScreencast", {
|
| 135 |
+
format: "jpeg",
|
| 136 |
+
quality: 60,
|
| 137 |
+
maxWidth: VIEWPORT.width,
|
| 138 |
+
maxHeight: VIEWPORT.height,
|
| 139 |
+
everyNthFrame: 1,
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
send({ type: "started", ...VIEWPORT, url: page.url() });
|
| 143 |
+
console.log("[browser] started");
|
| 144 |
+
return;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
if (!client || !page) return;
|
| 148 |
+
|
| 149 |
+
// ---------------------------------------------------------------- MOUSE
|
| 150 |
+
if (msg.type === "mousemove") {
|
| 151 |
+
await client.send("Input.dispatchMouseEvent", {
|
| 152 |
+
type: "mouseMoved",
|
| 153 |
+
x: msg.x,
|
| 154 |
+
y: msg.y,
|
| 155 |
+
});
|
| 156 |
+
} else if (msg.type === "mousedown" || msg.type === "mouseup") {
|
| 157 |
+
await client.send("Input.dispatchMouseEvent", {
|
| 158 |
+
type: msg.type === "mousedown" ? "mousePressed" : "mouseReleased",
|
| 159 |
+
x: msg.x,
|
| 160 |
+
y: msg.y,
|
| 161 |
+
button: msg.button === 2 ? "right" : "left",
|
| 162 |
+
clickCount: 1,
|
| 163 |
+
});
|
| 164 |
+
} else if (msg.type === "scroll") {
|
| 165 |
+
await client.send("Input.dispatchMouseEvent", {
|
| 166 |
+
type: "mouseWheel",
|
| 167 |
+
x: msg.x,
|
| 168 |
+
y: msg.y,
|
| 169 |
+
deltaX: msg.deltaX || 0,
|
| 170 |
+
deltaY: msg.deltaY || 0,
|
| 171 |
+
});
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// ------------------------------------------------------------- KEYBOARD
|
| 175 |
+
else if (msg.type === "keydown") {
|
| 176 |
+
const key = msg.key;
|
| 177 |
+
if (key && key.length === 1) {
|
| 178 |
+
// printable character
|
| 179 |
+
await client.send("Input.dispatchKeyEvent", {
|
| 180 |
+
type: "keyDown",
|
| 181 |
+
text: key,
|
| 182 |
+
key,
|
| 183 |
+
});
|
| 184 |
+
await client.send("Input.dispatchKeyEvent", {
|
| 185 |
+
type: "keyUp",
|
| 186 |
+
key,
|
| 187 |
+
});
|
| 188 |
+
} else if (KEY_CODES[key] !== undefined) {
|
| 189 |
+
// special key
|
| 190 |
+
const code = KEY_CODES[key];
|
| 191 |
+
await client.send("Input.dispatchKeyEvent", {
|
| 192 |
+
type: "rawKeyDown",
|
| 193 |
+
key,
|
| 194 |
+
windowsVirtualKeyCode: code,
|
| 195 |
+
nativeVirtualKeyCode: code,
|
| 196 |
+
});
|
| 197 |
+
await client.send("Input.dispatchKeyEvent", {
|
| 198 |
+
type: "keyUp",
|
| 199 |
+
key,
|
| 200 |
+
windowsVirtualKeyCode: code,
|
| 201 |
+
nativeVirtualKeyCode: code,
|
| 202 |
+
});
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
// ------------------------------------------------------------- NAVIGATE
|
| 207 |
+
else if (msg.type === "navigate" && msg.url) {
|
| 208 |
+
let url = msg.url.trim();
|
| 209 |
+
if (!/^https?:\/\//i.test(url)) url = "https://" + url;
|
| 210 |
+
await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => {});
|
| 211 |
+
send({ type: "navigated", url: page.url() });
|
| 212 |
+
}
|
| 213 |
+
} catch (err) {
|
| 214 |
+
console.error("[error]", err.message);
|
| 215 |
+
}
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
ws.on("close", () => {
|
| 219 |
+
console.log("[ws] client disconnected");
|
| 220 |
+
cleanup();
|
| 221 |
+
});
|
| 222 |
+
ws.on("error", cleanup);
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
server.listen(PORT, () => console.log(`PixelStream backend listening on :${PORT}`));
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
backend:
|
| 3 |
+
build: ./backend
|
| 4 |
+
ports:
|
| 5 |
+
- "8080:8080"
|
| 6 |
+
shm_size: "1gb" # prevents Chromium from crashing inside the container
|
| 7 |
+
environment:
|
| 8 |
+
- PORT=8080
|
| 9 |
+
- START_URL=https://www.google.com
|
| 10 |
+
|
| 11 |
+
frontend:
|
| 12 |
+
build: ./frontend
|
| 13 |
+
ports:
|
| 14 |
+
- "3000:3000"
|
| 15 |
+
environment:
|
| 16 |
+
# browser talks to the backend directly on the host port
|
| 17 |
+
- NEXT_PUBLIC_WS_URL=ws://localhost:8080
|
| 18 |
+
- WATCHPACK_POLLING=true # reliable hot-reload on Windows-mounted files
|
| 19 |
+
volumes:
|
| 20 |
+
- ./frontend:/app # live-mount source for hot reload
|
| 21 |
+
- /app/node_modules # keep container's node_modules
|
| 22 |
+
depends_on:
|
| 23 |
+
- backend
|
frontend/.dockerignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.next
|
| 3 |
+
npm-debug.log
|
| 4 |
+
.git
|
frontend/Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PixelStream frontend (Next.js, dev mode for the assignment)
|
| 2 |
+
FROM node:20-slim
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
COPY package.json ./
|
| 7 |
+
RUN npm install
|
| 8 |
+
|
| 9 |
+
COPY . .
|
| 10 |
+
|
| 11 |
+
EXPOSE 3000
|
| 12 |
+
CMD ["npm", "run", "dev"]
|
frontend/app/layout.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const metadata = {
|
| 2 |
+
title: "PixelStream — Remote Browser",
|
| 3 |
+
description: "Control a headless Chromium running in Docker, from your browser",
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
export default function RootLayout({ children }) {
|
| 7 |
+
return (
|
| 8 |
+
<html lang="en">
|
| 9 |
+
<body style={{ margin: 0 }}>{children}</body>
|
| 10 |
+
</html>
|
| 11 |
+
);
|
| 12 |
+
}
|
frontend/app/page.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 4 |
+
|
| 5 |
+
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8080";
|
| 6 |
+
|
| 7 |
+
export default function Home() {
|
| 8 |
+
const canvasRef = useRef(null);
|
| 9 |
+
const wsRef = useRef(null);
|
| 10 |
+
const dims = useRef({ width: 1280, height: 720 });
|
| 11 |
+
|
| 12 |
+
const [status, setStatus] = useState("idle"); // idle | connecting | live
|
| 13 |
+
const [url, setUrl] = useState("https://www.google.com");
|
| 14 |
+
|
| 15 |
+
const send = useCallback((obj) => {
|
| 16 |
+
const ws = wsRef.current;
|
| 17 |
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
| 18 |
+
}, []);
|
| 19 |
+
|
| 20 |
+
const start = useCallback(() => {
|
| 21 |
+
if (wsRef.current) return;
|
| 22 |
+
setStatus("connecting");
|
| 23 |
+
|
| 24 |
+
const ws = new WebSocket(WS_URL);
|
| 25 |
+
wsRef.current = ws;
|
| 26 |
+
|
| 27 |
+
ws.onopen = () => ws.send(JSON.stringify({ type: "start" }));
|
| 28 |
+
|
| 29 |
+
ws.onmessage = (e) => {
|
| 30 |
+
const msg = JSON.parse(e.data);
|
| 31 |
+
if (msg.type === "started") {
|
| 32 |
+
dims.current = { width: msg.width, height: msg.height };
|
| 33 |
+
if (msg.url) setUrl(msg.url);
|
| 34 |
+
setStatus("live");
|
| 35 |
+
} else if (msg.type === "navigated") {
|
| 36 |
+
if (msg.url) setUrl(msg.url);
|
| 37 |
+
} else if (msg.type === "frame") {
|
| 38 |
+
const img = new Image();
|
| 39 |
+
img.onload = () => {
|
| 40 |
+
const c = canvasRef.current;
|
| 41 |
+
if (!c) return;
|
| 42 |
+
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
|
| 43 |
+
};
|
| 44 |
+
img.src = "data:image/jpeg;base64," + msg.data;
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
ws.onclose = () => {
|
| 49 |
+
wsRef.current = null;
|
| 50 |
+
setStatus("idle");
|
| 51 |
+
};
|
| 52 |
+
ws.onerror = () => setStatus("idle");
|
| 53 |
+
}, []);
|
| 54 |
+
|
| 55 |
+
// map a DOM event on the canvas -> real browser pixel coordinates
|
| 56 |
+
const toBrowser = (e) => {
|
| 57 |
+
const r = canvasRef.current.getBoundingClientRect();
|
| 58 |
+
return {
|
| 59 |
+
x: Math.round(((e.clientX - r.left) / r.width) * dims.current.width),
|
| 60 |
+
y: Math.round(((e.clientY - r.top) / r.height) * dims.current.height),
|
| 61 |
+
};
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
// forward keyboard while live
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
const onKey = (e) => {
|
| 67 |
+
if (status !== "live") return;
|
| 68 |
+
// don't hijack typing in the URL bar
|
| 69 |
+
if (document.activeElement?.tagName === "INPUT") return;
|
| 70 |
+
e.preventDefault();
|
| 71 |
+
send({ type: "keydown", key: e.key });
|
| 72 |
+
};
|
| 73 |
+
window.addEventListener("keydown", onKey);
|
| 74 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 75 |
+
}, [status, send]);
|
| 76 |
+
|
| 77 |
+
const navigate = (e) => {
|
| 78 |
+
e.preventDefault();
|
| 79 |
+
send({ type: "navigate", url });
|
| 80 |
+
canvasRef.current?.focus();
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const live = status === "live";
|
| 84 |
+
|
| 85 |
+
return (
|
| 86 |
+
<div style={S.page}>
|
| 87 |
+
<header style={S.header}>
|
| 88 |
+
<h1 style={S.logo}>
|
| 89 |
+
Pixel<span style={{ color: "#6366f1" }}>Stream</span>
|
| 90 |
+
</h1>
|
| 91 |
+
<span style={S.badge(status)}>{status.toUpperCase()}</span>
|
| 92 |
+
</header>
|
| 93 |
+
|
| 94 |
+
{!live && (
|
| 95 |
+
<button onClick={start} disabled={status === "connecting"} style={S.startBtn}>
|
| 96 |
+
{status === "connecting" ? "Starting…" : "▶ Start Browser"}
|
| 97 |
+
</button>
|
| 98 |
+
)}
|
| 99 |
+
|
| 100 |
+
{live && (
|
| 101 |
+
<form onSubmit={navigate} style={S.urlBar}>
|
| 102 |
+
<input
|
| 103 |
+
value={url}
|
| 104 |
+
onChange={(e) => setUrl(e.target.value)}
|
| 105 |
+
style={S.urlInput}
|
| 106 |
+
placeholder="Enter a URL and press Go"
|
| 107 |
+
/>
|
| 108 |
+
<button type="submit" style={S.goBtn}>
|
| 109 |
+
Go
|
| 110 |
+
</button>
|
| 111 |
+
</form>
|
| 112 |
+
)}
|
| 113 |
+
|
| 114 |
+
<div style={S.stage}>
|
| 115 |
+
<canvas
|
| 116 |
+
ref={canvasRef}
|
| 117 |
+
width={1280}
|
| 118 |
+
height={720}
|
| 119 |
+
tabIndex={0}
|
| 120 |
+
style={{ ...S.canvas, display: live ? "block" : "none" }}
|
| 121 |
+
onMouseMove={(e) => send({ type: "mousemove", ...toBrowser(e) })}
|
| 122 |
+
onMouseDown={(e) => {
|
| 123 |
+
canvasRef.current.focus();
|
| 124 |
+
send({ type: "mousedown", ...toBrowser(e), button: e.button });
|
| 125 |
+
}}
|
| 126 |
+
onMouseUp={(e) => send({ type: "mouseup", ...toBrowser(e), button: e.button })}
|
| 127 |
+
onWheel={(e) =>
|
| 128 |
+
send({ type: "scroll", ...toBrowser(e), deltaX: e.deltaX, deltaY: e.deltaY })
|
| 129 |
+
}
|
| 130 |
+
onContextMenu={(e) => e.preventDefault()}
|
| 131 |
+
/>
|
| 132 |
+
{!live && (
|
| 133 |
+
<div style={S.placeholder}>
|
| 134 |
+
Click <b>Start Browser</b> to launch a headless Chromium and stream it here.
|
| 135 |
+
</div>
|
| 136 |
+
)}
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const S = {
|
| 143 |
+
page: {
|
| 144 |
+
minHeight: "100vh",
|
| 145 |
+
background:
|
| 146 |
+
"linear-gradient(135deg, #ffffff 0%, #f5f3ff 35%, #ede9fe 70%, #ddd6fe 100%)",
|
| 147 |
+
color: "#3b0764",
|
| 148 |
+
fontFamily: "system-ui, sans-serif",
|
| 149 |
+
padding: 24,
|
| 150 |
+
boxSizing: "border-box",
|
| 151 |
+
display: "flex",
|
| 152 |
+
flexDirection: "column",
|
| 153 |
+
alignItems: "center", // center everything horizontally
|
| 154 |
+
},
|
| 155 |
+
header: {
|
| 156 |
+
display: "flex",
|
| 157 |
+
alignItems: "center",
|
| 158 |
+
justifyContent: "center",
|
| 159 |
+
gap: 12,
|
| 160 |
+
marginBottom: 20,
|
| 161 |
+
},
|
| 162 |
+
logo: { margin: 0, fontSize: 28, fontWeight: 800, letterSpacing: -0.5, color: "#4c1d95" },
|
| 163 |
+
badge: (s) => ({
|
| 164 |
+
fontSize: 12,
|
| 165 |
+
fontWeight: 700,
|
| 166 |
+
padding: "4px 10px",
|
| 167 |
+
borderRadius: 999,
|
| 168 |
+
background: s === "live" ? "#7c3aed" : s === "connecting" ? "#a78bfa" : "#e9d5ff",
|
| 169 |
+
color: s === "idle" ? "#6d28d9" : "#fff",
|
| 170 |
+
}),
|
| 171 |
+
startBtn: {
|
| 172 |
+
fontSize: 16,
|
| 173 |
+
fontWeight: 700,
|
| 174 |
+
padding: "12px 22px",
|
| 175 |
+
borderRadius: 10,
|
| 176 |
+
border: "none",
|
| 177 |
+
background: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
| 178 |
+
color: "#fff",
|
| 179 |
+
cursor: "pointer",
|
| 180 |
+
marginBottom: 20,
|
| 181 |
+
boxShadow: "0 6px 18px rgba(124,58,237,0.35)",
|
| 182 |
+
},
|
| 183 |
+
urlBar: { display: "flex", gap: 8, marginBottom: 16, width: 960, maxWidth: "100%" },
|
| 184 |
+
urlInput: {
|
| 185 |
+
flex: 1,
|
| 186 |
+
padding: "10px 14px",
|
| 187 |
+
borderRadius: 8,
|
| 188 |
+
border: "1px solid #c4b5fd",
|
| 189 |
+
background: "rgba(255,255,255,0.85)",
|
| 190 |
+
color: "#4c1d95",
|
| 191 |
+
fontSize: 14,
|
| 192 |
+
outline: "none",
|
| 193 |
+
},
|
| 194 |
+
goBtn: {
|
| 195 |
+
padding: "10px 20px",
|
| 196 |
+
borderRadius: 8,
|
| 197 |
+
border: "none",
|
| 198 |
+
background: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
| 199 |
+
color: "#fff",
|
| 200 |
+
fontWeight: 700,
|
| 201 |
+
cursor: "pointer",
|
| 202 |
+
},
|
| 203 |
+
stage: {
|
| 204 |
+
width: 960,
|
| 205 |
+
height: 540,
|
| 206 |
+
maxWidth: "100%",
|
| 207 |
+
background: "#1e1b2e",
|
| 208 |
+
borderRadius: 12,
|
| 209 |
+
overflow: "hidden",
|
| 210 |
+
border: "1px solid #c4b5fd",
|
| 211 |
+
position: "relative",
|
| 212 |
+
boxShadow: "0 10px 30px rgba(124,58,237,0.18)",
|
| 213 |
+
},
|
| 214 |
+
canvas: { width: "100%", height: "100%", cursor: "crosshair", outline: "none" },
|
| 215 |
+
placeholder: {
|
| 216 |
+
position: "absolute",
|
| 217 |
+
inset: 0,
|
| 218 |
+
display: "flex",
|
| 219 |
+
alignItems: "center",
|
| 220 |
+
justifyContent: "center",
|
| 221 |
+
color: "#a78bfa",
|
| 222 |
+
fontSize: 15,
|
| 223 |
+
textAlign: "center",
|
| 224 |
+
padding: 24,
|
| 225 |
+
},
|
| 226 |
+
};
|
frontend/next.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
reactStrictMode: false, // avoid double WebSocket connects in dev
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
module.exports = nextConfig;
|
frontend/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "pixelstream-frontend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start"
|
| 9 |
+
},
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"next": "14.2.5",
|
| 12 |
+
"react": "^18.3.1",
|
| 13 |
+
"react-dom": "^18.3.1"
|
| 14 |
+
}
|
| 15 |
+
}
|
render.yaml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Render Blueprint — deploys the PixelStream backend (Node + Puppeteer + Chromium)
|
| 2 |
+
# Create a new "Blueprint" on Render and point it at this repo, or create a
|
| 3 |
+
# Web Service manually with: Runtime = Docker, Dockerfile path = backend/Dockerfile.
|
| 4 |
+
services:
|
| 5 |
+
- type: web
|
| 6 |
+
name: pixelstream-backend
|
| 7 |
+
runtime: docker
|
| 8 |
+
dockerfilePath: ./backend/Dockerfile
|
| 9 |
+
dockerContext: ./backend
|
| 10 |
+
# Chromium needs memory — the free 512MB tier can OOM under load.
|
| 11 |
+
# "starter" (or higher) is recommended; "free" works for light demo use.
|
| 12 |
+
plan: free
|
| 13 |
+
healthCheckPath: /
|
| 14 |
+
envVars:
|
| 15 |
+
- key: START_URL
|
| 16 |
+
value: https://duckduckgo.com
|