playwrightt / server.js
nekoyama12's picture
Update server.js
980bcc3 verified
Raw
History Blame Contribute Delete
10.2 kB
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()