Spaces:
Paused
Paused
| const express = require('express') | |
| const { chromium } = require('playwright') | |
| const fs = require('fs') | |
| const path = require('path') | |
| const http = require('http') | |
| const https = require('https') | |
| const app = express() | |
| app.use(express.json()) | |
| const PORT = parseInt(process.env.PORT || '7860') | |
| const MAX_WORKERS = 4 | |
| const JOB_EXPIRY_MS = 30 * 60 * 1000 | |
| const SCREENSHOT_EXPIRY_MS = 10 * 60 * 1000 | |
| const HARD_TIMEOUT_MS = 120000 | |
| const SCREENSHOT_BASE_URL = process.env.SCREENSHOT_BASE_URL || `http://localhost:${PORT}` | |
| const PUBLIC_DIR = path.join(__dirname, 'public') | |
| if (!fs.existsSync(PUBLIC_DIR)) fs.mkdirSync(PUBLIC_DIR) | |
| const rooms = new Map() | |
| const jobs = new Map() | |
| let availableRooms = [] | |
| let nextRoomId = 1 | |
| function makeJobId() { | |
| const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' | |
| let id | |
| do { id = Array.from({ length: 3 }, () => chars[Math.floor(Math.random() * chars.length)]).join('') } | |
| while (jobs.has(id)) | |
| return id | |
| } | |
| async function createRoom() { | |
| const roomId = nextRoomId++ | |
| const browser = await chromium.launch({ | |
| headless: true, | |
| args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-blink-features=AutomationControlled'] | |
| }) | |
| const screenshotsDir = path.join(PUBLIC_DIR, `room_${roomId}`) | |
| if (!fs.existsSync(screenshotsDir)) fs.mkdirSync(screenshotsDir) | |
| const room = { roomId, browser, context: null, page: null, cookies: [], isBusy: false, currentJobId: null, lastActivity: Date.now(), screenshotsDir } | |
| rooms.set(roomId, room) | |
| return room | |
| } | |
| async function resetRoomContext(room) { | |
| try { | |
| if (room.page) { try { await room.page.close() } catch {} room.page = null } | |
| if (room.context) { | |
| try { room.cookies = await room.context.cookies() } catch {} | |
| try { await room.context.close() } catch {} | |
| room.context = null | |
| } | |
| } catch (e) {} | |
| } | |
| function cleanupScreenshots(room) { | |
| try { | |
| const now = Date.now() | |
| for (const f of fs.readdirSync(room.screenshotsDir)) { | |
| const fp = path.join(room.screenshotsDir, f) | |
| if (now - fs.statSync(fp).mtimeMs > SCREENSHOT_EXPIRY_MS) fs.unlinkSync(fp) | |
| } | |
| } catch {} | |
| } | |
| function acquireRoom(jobId) { | |
| return new Promise((resolve, reject) => { | |
| const start = Date.now() | |
| const tryAcquire = () => { | |
| const idx = availableRooms.findIndex(id => { const r = rooms.get(id); return r && !r.isBusy }) | |
| if (idx !== -1) { | |
| const roomId = availableRooms.splice(idx, 1)[0] | |
| const room = rooms.get(roomId) | |
| room.isBusy = true | |
| room.currentJobId = jobId | |
| room.lastActivity = Date.now() | |
| return resolve(room) | |
| } | |
| if (Date.now() - start > 120000) return reject(new Error('Timeout waiting for available room')) | |
| setTimeout(tryAcquire, 1000) | |
| } | |
| tryAcquire() | |
| }) | |
| } | |
| function releaseRoom(room) { | |
| room.isBusy = false | |
| room.currentJobId = null | |
| room.lastActivity = Date.now() | |
| availableRooms = availableRooms.filter(id => id !== room.roomId) | |
| availableRooms.push(room.roomId) | |
| } | |
| async function executeInRoom(code, room, jobId) { | |
| const result = { screenshots: [], data: null, error: null } | |
| await resetRoomContext(room) | |
| try { | |
| const context = await room.browser.newContext({ | |
| viewport: { width: 1920, height: 1080 }, | |
| userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', | |
| ignoreHTTPSErrors: true, | |
| locale: 'en-US', | |
| }) | |
| room.context = context | |
| if (room.cookies.length > 0) { try { await context.addCookies(room.cookies) } catch {} } | |
| const page = await context.newPage() | |
| room.page = page | |
| const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor | |
| const fn = new AsyncFunction('browser', 'context', 'page', 'roomCookies', 'publicDir', 'rootPublicDir', 'roomId', 'jobId', 'result', code) | |
| const hardTimeout = new Promise((_, rej) => setTimeout(() => rej(new Error(`Hard timeout after ${HARD_TIMEOUT_MS}ms`)), HARD_TIMEOUT_MS)) | |
| const returnValue = await Promise.race([ | |
| fn(room.browser, context, page, room.cookies, room.screenshotsDir, PUBLIC_DIR, room.roomId, jobId, result), | |
| hardTimeout | |
| ]) | |
| if (returnValue !== undefined) result.data = returnValue | |
| } catch (e) { | |
| result.error = e.message + '\n' + e.stack | |
| } finally { | |
| await resetRoomContext(room) | |
| } | |
| return result | |
| } | |
| async function processJob(job) { | |
| let room = null | |
| try { | |
| job.status = 'running' | |
| job.startedAt = new Date() | |
| room = await acquireRoom(job.jobId) | |
| job.roomId = room.roomId | |
| const result = await executeInRoom(job.code, room, job.jobId) | |
| job.screenshots = fs.readdirSync(room.screenshotsDir) | |
| .filter(f => f.endsWith('.png')) | |
| .sort((a, b) => fs.statSync(path.join(room.screenshotsDir, a)).mtimeMs - fs.statSync(path.join(room.screenshotsDir, b)).mtimeMs) | |
| .map(f => ({ name: f, publicURL: `${SCREENSHOT_BASE_URL}/files/room_${room.roomId}/${f}` })) | |
| job.completedAt = new Date() | |
| if (result.error) { job.status = 'failed'; job.error = result.error } | |
| else { job.status = 'completed'; job.result = result.data } | |
| } catch (e) { | |
| job.status = 'failed' | |
| job.error = e.message + '\n' + e.stack | |
| job.completedAt = new Date() | |
| } finally { | |
| if (room) { cleanupScreenshots(room); releaseRoom(room) } | |
| } | |
| } | |
| function repairRooms() { | |
| for (const room of rooms.values()) { | |
| if (!room.isBusy && !availableRooms.includes(room.roomId)) availableRooms.push(room.roomId) | |
| } | |
| } | |
| function streamProxy(targetUrl, cookie, referer, req, res) { | |
| const doRequest = (url, redirectCount = 0) => { | |
| if (redirectCount > 10) return res.status(500).send('Too many redirects') | |
| const mod = url.startsWith('https') ? https : http | |
| mod.get(url, { | |
| headers: { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', | |
| 'Referer': referer || 'https://vidssave.com/', | |
| 'Origin': 'https://vidssave.com', | |
| ...(cookie ? { 'Cookie': cookie } : {}), | |
| ...(req.headers.range ? { 'Range': req.headers.range } : {}) | |
| } | |
| }, (upstream) => { | |
| if (upstream.statusCode >= 300 && upstream.statusCode < 400 && upstream.headers.location) { | |
| const loc = upstream.headers.location | |
| return doRequest(loc.startsWith('http') ? loc : new URL(loc, url).href, redirectCount + 1) | |
| } | |
| const headers = { | |
| 'Content-Type': upstream.headers['content-type'] || 'application/octet-stream', | |
| 'Accept-Ranges': 'bytes', | |
| 'Access-Control-Allow-Origin': '*', | |
| } | |
| if (upstream.headers['content-length']) headers['Content-Length'] = upstream.headers['content-length'] | |
| if (upstream.headers['content-range']) headers['Content-Range'] = upstream.headers['content-range'] | |
| if (upstream.headers['content-disposition']) headers['Content-Disposition'] = upstream.headers['content-disposition'] | |
| res.writeHead(upstream.statusCode, headers) | |
| upstream.pipe(res) | |
| upstream.on('error', () => res.end()) | |
| }).on('error', (e) => { | |
| if (!res.headersSent) res.status(500).send(e.message) | |
| }) | |
| } | |
| doRequest(targetUrl) | |
| } | |
| app.get('/proxy', (req, res) => { | |
| const targetUrl = req.query.url | |
| if (!targetUrl) return res.status(400).json({ error: 'Missing url parameter' }) | |
| const cookie = req.query.cookie ? decodeURIComponent(req.query.cookie) : '' | |
| const referer = req.query.referer ? decodeURIComponent(req.query.referer) : 'https://vidssave.com/' | |
| streamProxy(decodeURIComponent(targetUrl), cookie, referer, req, res) | |
| }) | |
| app.post('/api/s-playwright', (req, res) => { | |
| try { | |
| const { code, lang } = req.body || {} | |
| if (!code || !lang) return res.status(400).json({ success: false, error: 'Missing code or lang' }) | |
| if (lang.toLowerCase() !== 'js') return res.status(400).json({ success: false, error: `Only JS supported, got: ${lang}` }) | |
| const jobId = makeJobId() | |
| const job = { jobId, code, lang, status: 'queued', result: null, error: null, createdAt: new Date(), startedAt: null, completedAt: null, roomId: null, screenshots: [] } | |
| jobs.set(jobId, job) | |
| processJob(job) | |
| return res.json({ success: true, jobId, status: 'queued', checkUrl: `/job/${jobId}` }) | |
| } catch (e) { | |
| return res.status(500).json({ success: false, error: e.message }) | |
| } | |
| }) | |
| app.get('/job/:id', (req, res) => { | |
| const job = jobs.get(req.params.id.toUpperCase()) | |
| if (!job) return res.status(404).json({ success: false, error: 'Job not found' }) | |
| res.json({ success: true, job }) | |
| }) | |
| app.use('/files', express.static(PUBLIC_DIR)) | |
| app.get('/health', (req, res) => { | |
| repairRooms() | |
| const jobList = [...jobs.values()] | |
| res.json({ | |
| status: 'healthy', | |
| rooms: { total: MAX_WORKERS, available: availableRooms.length, busy: [...rooms.values()].filter(r => r.isBusy).length }, | |
| jobs: { total: jobList.length, queued: jobList.filter(j => j.status === 'queued').length, running: jobList.filter(j => j.status === 'running').length }, | |
| timestamp: Date.now(), | |
| }) | |
| }) | |
| app.get('/', (req, res) => { | |
| res.json({ | |
| message: 'Playwright API', | |
| endpoints: { | |
| 'POST /api/s-playwright': '{ code, lang: "js" }', | |
| 'GET /job/:id': 'Check job status', | |
| 'GET /proxy?url=&cookie=&referer=': 'Stream proxy', | |
| 'GET /health': 'Health check', | |
| }, | |
| availableVars: ['browser', 'context', 'page', 'roomCookies', 'publicDir', 'rootPublicDir', 'roomId', 'jobId', 'result'], | |
| }) | |
| }) | |
| async function init() { | |
| for (let i = 0; i < MAX_WORKERS; i++) { | |
| try { | |
| const room = await createRoom() | |
| availableRooms.push(room.roomId) | |
| console.log(`[POOL] Room ${room.roomId} ready`) | |
| } catch (e) { | |
| console.error(`[POOL] Failed to create room:`, e.message) | |
| } | |
| } | |
| setInterval(() => { | |
| repairRooms() | |
| const now = Date.now() | |
| for (const [id, job] of jobs.entries()) { | |
| if (now - new Date(job.createdAt).getTime() > JOB_EXPIRY_MS) jobs.delete(id) | |
| } | |
| for (const room of rooms.values()) { if (!room.isBusy) cleanupScreenshots(room) } | |
| }, 30000) | |
| app.listen(PORT, () => console.log(`Running on :${PORT}`)) | |
| } | |
| init() |