-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
273 lines (232 loc) · 9.81 KB
/
server.js
File metadata and controls
273 lines (232 loc) · 9.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { chromium, firefox, webkit } from 'playwright';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
const app = new Hono();
// Configuration (can be overridden by ENV)
const API_KEY = process.env.RENDERER_API_KEY || 'supersecret123';
const PORT = parseInt(process.env.PORT || '3000', 10);
const MAX_CONCURRENCY = parseInt(process.env.MAX_CONCURRENCY || '2', 10);
const BROWSER_ENGINE = process.env.BROWSER_ENGINE || 'chromium'; // chromium | firefox | webkit
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS || '*';
let browser = null;
let active = 0; // simple concurrency counter
// === Middleware ===
app.use('*', logger());
app.use('*', cors({
origin: ALLOWED_ORIGINS === '*' ? '*' : ALLOWED_ORIGINS.split(','),
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
}));
// === Helper: Auth middleware (simple Bearer token) ===
app.use('/screenshot', async (c, next) => {
const auth = c.req.header('authorization') || '';
if (!auth.startsWith('Bearer ')) return c.json({ error: 'Unauthorized' }, 401);
const token = auth.slice(7).trim();
if (!token || token !== API_KEY) return c.json({ error: 'Unauthorized' }, 401);
await next();
});
app.use('/pdf', async (c, next) => {
const auth = c.req.header('authorization') || '';
if (!auth.startsWith('Bearer ')) return c.json({ error: 'Unauthorized' }, 401);
const token = auth.slice(7).trim();
if (!token || token !== API_KEY) return c.json({ error: 'Unauthorized' }, 401);
await next();
});
app.use('/evaluate', async (c, next) => {
const auth = c.req.header('authorization') || '';
if (!auth.startsWith('Bearer ')) return c.json({ error: 'Unauthorized' }, 401);
const token = auth.slice(7).trim();
if (!token || token !== API_KEY) return c.json({ error: 'Unauthorized' }, 401);
await next();
});
// === Helper: Acquire browser ===
async function getBrowser() {
if (browser) return browser;
const engine = (BROWSER_ENGINE || 'chromium').toLowerCase();
if (engine === 'firefox') {
browser = await firefox.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
} else if (engine === 'webkit') {
browser = await webkit.launch({ headless: true });
} else {
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
});
}
return browser;
}
// === Utility: normalize waitUntil values ===
function normalizeWaitUntil(input) {
if (!input) return 'networkidle';
const v = String(input).toLowerCase();
// Playwright supports: 'load', 'domcontentloaded', 'networkidle'
if (v === 'networkidle0' || v === 'networkidle2' || v === 'networkidle') return 'networkidle';
if (v === 'domcontentloaded') return 'domcontentloaded';
if (v === 'load') return 'load';
return 'networkidle';
}
// === Basic JSON validation ===
function validateScreenshotBody(body) {
if (!body || typeof body !== 'object') return 'body must be a JSON object';
if (!body.url || typeof body.url !== 'string') return 'url (string) is required';
if (body.viewport) {
if (typeof body.viewport.width !== 'number' || typeof body.viewport.height !== 'number') {
return 'viewport must contain numeric width and height';
}
}
return null;
}
// === Root endpoint ===
app.get('/', (c) => {
return c.json({
name: 'Open Browser Rendering',
version: '1.0.0',
description: 'Open-source alternative to Cloudflare Browser Rendering API',
endpoints: {
screenshot: 'POST /screenshot',
pdf: 'POST /pdf',
evaluate: 'POST /evaluate',
health: 'GET /health'
},
documentation: 'https://github.com/yourusername/OpenBrowserRendering'
});
});
// === Screenshot Endpoint ===
app.post('/screenshot', async (c) => {
// Concurrency check
if (active >= MAX_CONCURRENCY) {
return c.json({ error: 'Too many concurrent requests' }, 429);
}
active++;
try {
const body = await c.req.json().catch(() => null);
const validationError = validateScreenshotBody(body);
if (validationError) return c.json({ error: validationError }, 400);
const url = body.url;
const viewport = body.viewport || { width: 1280, height: 720 };
const screenshotOptions = body.screenshotOptions || {};
const gotoOptions = body.gotoOptions || {};
// Normalize options
const type = screenshotOptions.type === 'jpeg' ? 'jpeg' : 'png';
const fullPage = Boolean(screenshotOptions.fullPage === true);
const quality = typeof screenshotOptions.quality === 'number' ? screenshotOptions.quality : undefined; // for jpeg
const waitUntil = normalizeWaitUntil(gotoOptions.waitUntil);
const timeout = typeof gotoOptions.timeout === 'number' ? gotoOptions.timeout : 30000;
const browser = await getBrowser();
const context = await browser.newContext({ viewport });
const page = await context.newPage();
// Navigate
await page.goto(url, { waitUntil, timeout });
const opts = { fullPage, type };
if (type === 'jpeg' && typeof quality === 'number') opts.quality = Math.max(1, Math.min(100, Math.floor(quality)));
const buffer = await page.screenshot(opts);
await context.close();
const contentType = type === 'png' ? 'image/png' : 'image/jpeg';
return new Response(buffer, { headers: { 'Content-Type': contentType } });
} catch (err) {
console.error('screenshot error', err);
return c.json({ error: (err && err.message) || String(err) }, 500);
} finally {
active--;
}
});
// === PDF Endpoint ===
app.post('/pdf', async (c) => {
if (active >= MAX_CONCURRENCY) return c.json({ error: 'Too many concurrent requests' }, 429);
active++;
try {
const body = await c.req.json().catch(() => null);
if (!body || typeof body.url !== 'string') return c.json({ error: 'url (string) is required' }, 400);
const url = body.url;
const format = body.format || 'A4';
const printBackground = body.printBackground !== false; // default true
const gotoOptions = body.gotoOptions || {};
const waitUntil = normalizeWaitUntil(gotoOptions.waitUntil);
const timeout = typeof gotoOptions.timeout === 'number' ? gotoOptions.timeout : 30000;
const browser = await getBrowser();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(url, { waitUntil, timeout });
const pdfBuffer = await page.pdf({ format, printBackground });
await context.close();
return new Response(pdfBuffer, { headers: { 'Content-Type': 'application/pdf' } });
} catch (err) {
console.error('pdf error', err);
return c.json({ error: (err && err.message) || String(err) }, 500);
} finally {
active--;
}
});
// === Evaluate Endpoint (safe-ish: runs inside page context) ===
app.post('/evaluate', async (c) => {
if (active >= MAX_CONCURRENCY) return c.json({ error: 'Too many concurrent requests' }, 429);
active++;
try {
const body = await c.req.json().catch(() => null);
if (!body || typeof body.url !== 'string' || typeof body.script !== 'string') {
return c.json({ error: 'url (string) and script (string) are required' }, 400);
}
const url = body.url;
const script = body.script; // expected: a JS expression or function body that returns
const gotoOptions = body.gotoOptions || {};
const waitUntil = normalizeWaitUntil(gotoOptions.waitUntil);
const timeout = typeof gotoOptions.timeout === 'number' ? gotoOptions.timeout : 30000;
const browser = await getBrowser();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(url, { waitUntil, timeout });
// We evaluate script inside page context; wrap to return value
const wrapped = `(function(){ try { return (${script}); } catch(e) { return {__error: String(e)}; } })()`;
const result = await page.evaluate(wrapped);
await context.close();
if (result && result.__error) return c.json({ error: result.__error }, 500);
return c.json({ result });
} catch (err) {
console.error('evaluate error', err);
return c.json({ error: (err && err.message) || String(err) }, 500);
} finally {
active--;
}
});
// === Health & metrics endpoints ===
app.get('/health', (c) => c.json({
status: 'ok',
active,
maxConcurrency: MAX_CONCURRENCY,
browserEngine: BROWSER_ENGINE,
uptime: process.uptime()
}));
// === Start server (using Node's http serve wrapper) ===
console.log(`🚀 Open Browser Rendering Server`);
console.log(`📍 Port: ${PORT}`);
console.log(`🌐 Browser Engine: ${BROWSER_ENGINE}`);
console.log(`⚡ Max Concurrency: ${MAX_CONCURRENCY}`);
console.log(`🔐 API Key: ${API_KEY === 'supersecret123' ? '⚠️ Using default key (change in production!)' : '✓ Custom key set'}`);
serve({
fetch: app.fetch,
port: PORT
});
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\n🛑 SIGINT received: closing browser');
try {
if (browser) await browser.close();
} catch (e) {
console.error('Error closing browser:', e);
}
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('\n🛑 SIGTERM received: closing browser');
try {
if (browser) await browser.close();
} catch (e) {
console.error('Error closing browser:', e);
}
process.exit(0);
});