Abhisingh-18 commited on
Commit
0266b68
·
verified ·
1 Parent(s): 9c7b997

Mirror of github.com/Abhisingh18/PixelStream-BLD

Browse files
.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