Add GET endpoint support, response caching, and update landing page
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m11s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m11s
- Add GET /v1/screenshot endpoint with query parameter support - Support API key authentication via ?key= query parameter - Implement in-memory LRU cache with configurable TTL (5min) and size limits (100MB) - Add X-Cache headers (HIT/MISS) to indicate cache status - Add cache bypass option via ?cache=false parameter - Update OpenAPI documentation with GET endpoint and caching info - Add GET/Embed code examples to landing page hero section - Add Response Caching and GET Request Support feature cards - Update features grid layout to accommodate new features
This commit is contained in:
parent
609e7d0808
commit
44e31e355c
5 changed files with 433 additions and 18 deletions
|
|
@ -85,7 +85,7 @@ nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-fi
|
||||||
.stat:last-child{border-right:none}
|
.stat:last-child{border-right:none}
|
||||||
.stat .number{font-size:2rem;font-weight:800;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
.stat .number{font-size:2rem;font-weight:800;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||||
.stat .label{font-size:.82rem;color:var(--muted);margin-top:4px;font-weight:500}
|
.stat .label{font-size:.82rem;color:var(--muted);margin-top:4px;font-weight:500}
|
||||||
.features-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;margin-top:48px}
|
.features-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:24px;margin-top:48px}
|
||||||
.feature-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:36px 28px;transition:all .3s}
|
.feature-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:36px 28px;transition:all .3s}
|
||||||
.feature-card:hover{border-color:var(--border-light);background:var(--card-hover);transform:translateY(-2px)}
|
.feature-card:hover{border-color:var(--border-light);background:var(--card-hover);transform:translateY(-2px)}
|
||||||
.feature-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:1.5rem;margin-bottom:20px}
|
.feature-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:1.5rem;margin-bottom:20px}
|
||||||
|
|
@ -270,6 +270,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
||||||
<div class="code-dot"></div><div class="code-dot"></div><div class="code-dot"></div>
|
<div class="code-dot"></div><div class="code-dot"></div><div class="code-dot"></div>
|
||||||
<div class="code-tabs">
|
<div class="code-tabs">
|
||||||
<button class="code-tab active" onclick="switchCodeTab(this, 'code-curl')">cURL</button>
|
<button class="code-tab active" onclick="switchCodeTab(this, 'code-curl')">cURL</button>
|
||||||
|
<button class="code-tab" onclick="switchCodeTab(this, 'code-get')">GET/Embed</button>
|
||||||
<button class="code-tab" onclick="switchCodeTab(this, 'code-node')">Node.js</button>
|
<button class="code-tab" onclick="switchCodeTab(this, 'code-node')">Node.js</button>
|
||||||
<button class="code-tab" onclick="switchCodeTab(this, 'code-python')">Python</button>
|
<button class="code-tab" onclick="switchCodeTab(this, 'code-python')">Python</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -282,6 +283,19 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
|
||||||
<span class="flag">-H</span> <span class="str">"Content-Type: application/json"</span> \
|
<span class="flag">-H</span> <span class="str">"Content-Type: application/json"</span> \
|
||||||
<span class="flag">-d</span> <span class="str">'{"url":"https://example.com","format":"png"}'</span> \
|
<span class="flag">-d</span> <span class="str">'{"url":"https://example.com","format":"png"}'</span> \
|
||||||
<span class="flag">-o</span> <span class="str">screenshot.png</span>
|
<span class="flag">-o</span> <span class="str">screenshot.png</span>
|
||||||
|
</div>
|
||||||
|
<div class="code-body" id="code-get" style="display:none">
|
||||||
|
<span class="cmt"># GET request with query parameters</span>
|
||||||
|
<span class="kw">curl</span> <span class="url">"https://snapapi.eu/v1/screenshot?url=https://example.com&key=YOUR_API_KEY&format=png&width=1920"</span>
|
||||||
|
|
||||||
|
<span class="cmt"># Direct image embedding in HTML</span>
|
||||||
|
<<span class="kw">img</span> <span class="prop">src</span>=<span class="str">"https://snapapi.eu/v1/screenshot?url=https://example.com&key=YOUR_API_KEY"</span>
|
||||||
|
<span class="prop">alt</span>=<span class="str">"Screenshot of example.com"</span>>
|
||||||
|
|
||||||
|
<span class="cmt"># Cached responses (5-min TTL)</span>
|
||||||
|
<span class="kw">curl</span> <span class="url">"https://snapapi.eu/v1/screenshot?url=https://example.com&key=YOUR_API_KEY"</span>
|
||||||
|
<span class="cmt"># First request: X-Cache: MISS</span>
|
||||||
|
<span class="cmt"># Next 5 minutes: X-Cache: HIT</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="code-body" id="code-node" style="display:none">
|
<div class="code-body" id="code-node" style="display:none">
|
||||||
<span class="cmt">// npm install snapapi</span>
|
<span class="cmt">// npm install snapapi</span>
|
||||||
|
|
@ -448,6 +462,16 @@ screenshot = snap.<span class="fn">capture</span>(
|
||||||
<h3>Wait for Elements</h3>
|
<h3>Wait for Elements</h3>
|
||||||
<p>Use CSS selectors to wait for specific elements before capturing. Ideal for SPAs and dynamic content.</p>
|
<p>Use CSS selectors to wait for specific elements before capturing. Ideal for SPAs and dynamic content.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon purple">⚡</div>
|
||||||
|
<h3>Response Caching</h3>
|
||||||
|
<p>Automatic 5-minute caching for repeat requests. Faster responses and reduced server load. Bypass with cache=false.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon orange">🔗</div>
|
||||||
|
<h3>GET Request Support</h3>
|
||||||
|
<p>Direct image embedding with GET requests. Perfect for <img> tags and markdown. API key via query parameter.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ const options: swaggerJsdoc.Options = {
|
||||||
"## Authentication\n" +
|
"## Authentication\n" +
|
||||||
"API screenshot requests require an API key:\n" +
|
"API screenshot requests require an API key:\n" +
|
||||||
"- `Authorization: Bearer YOUR_API_KEY` header, or\n" +
|
"- `Authorization: Bearer YOUR_API_KEY` header, or\n" +
|
||||||
"- `X-API-Key: YOUR_API_KEY` header\n\n" +
|
"- `X-API-Key: YOUR_API_KEY` header, or\n" +
|
||||||
|
"- `?key=YOUR_API_KEY` query parameter (for GET requests)\n\n" +
|
||||||
|
"## Response Caching\n" +
|
||||||
|
"Screenshot responses are cached for 5 minutes (authenticated requests only). Add `?cache=false` or `\"cache\": false` to bypass cache. Cache status is indicated via `X-Cache` header (HIT/MISS).\n\n" +
|
||||||
"## Playground\n" +
|
"## Playground\n" +
|
||||||
"The `/v1/playground` endpoint requires no authentication but returns watermarked screenshots (5 requests/hour per IP).\n\n" +
|
"The `/v1/playground` endpoint requires no authentication but returns watermarked screenshots (5 requests/hour per IP).\n\n" +
|
||||||
"## Rate Limits\n" +
|
"## Rate Limits\n" +
|
||||||
|
|
@ -37,6 +40,7 @@ const options: swaggerJsdoc.Options = {
|
||||||
securitySchemes: {
|
securitySchemes: {
|
||||||
BearerAuth: { type: "http", scheme: "bearer" },
|
BearerAuth: { type: "http", scheme: "bearer" },
|
||||||
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
|
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
|
||||||
|
QueryKeyAuth: { type: "apiKey", in: "query", name: "key" },
|
||||||
},
|
},
|
||||||
schemas: {
|
schemas: {
|
||||||
Error: {
|
Error: {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,15 @@ import { isValidKey, getKeyInfo } from "../services/keys.js";
|
||||||
export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
|
export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
const header = req.headers.authorization;
|
const header = req.headers.authorization;
|
||||||
const xApiKey = req.headers["x-api-key"] as string | undefined;
|
const xApiKey = req.headers["x-api-key"] as string | undefined;
|
||||||
|
const queryKey = req.query.key as string | undefined;
|
||||||
let key: string | undefined;
|
let key: string | undefined;
|
||||||
|
|
||||||
if (header?.startsWith("Bearer ")) key = header.slice(7);
|
if (header?.startsWith("Bearer ")) key = header.slice(7);
|
||||||
else if (xApiKey) key = xApiKey;
|
else if (xApiKey) key = xApiKey;
|
||||||
|
else if (queryKey) key = queryKey;
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key> or X-API-Key: <key>" });
|
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key>, X-API-Key: <key>, or ?key=<key>" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(await isValidKey(key))) {
|
if (!(await isValidKey(key))) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { takeScreenshot } from "../services/screenshot.js";
|
import { takeScreenshot } from "../services/screenshot.js";
|
||||||
|
import { screenshotCache } from "../services/cache.js";
|
||||||
import logger from "../services/logger.js";
|
import logger from "../services/logger.js";
|
||||||
|
|
||||||
export const screenshotRouter = Router();
|
export const screenshotRouter = Router();
|
||||||
|
|
@ -128,32 +129,217 @@ export const screenshotRouter = Router();
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema: { $ref: "#/components/schemas/Error" }
|
* schema: { $ref: "#/components/schemas/Error" }
|
||||||
|
* get:
|
||||||
|
* tags: [Screenshots]
|
||||||
|
* summary: Take a screenshot via GET request (authenticated)
|
||||||
|
* description: >
|
||||||
|
* Capture a pixel-perfect, unwatermarked screenshot using GET request.
|
||||||
|
* All parameters are passed via query string. Perfect for image embeds:
|
||||||
|
* `<img src="https://snapapi.eu/v1/screenshot?url=https://example.com&key=YOUR_KEY">`
|
||||||
|
* operationId: takeScreenshotGet
|
||||||
|
* security:
|
||||||
|
* - BearerAuth: []
|
||||||
|
* - ApiKeyAuth: []
|
||||||
|
* - QueryKeyAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - name: url
|
||||||
|
* in: query
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uri
|
||||||
|
* description: URL to capture
|
||||||
|
* example: https://example.com
|
||||||
|
* - name: key
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: API key (alternative to header auth)
|
||||||
|
* example: sk_test_1234567890abcdef
|
||||||
|
* - name: format
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [png, jpeg, webp]
|
||||||
|
* default: png
|
||||||
|
* description: Output image format
|
||||||
|
* - name: width
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 320
|
||||||
|
* maximum: 3840
|
||||||
|
* default: 1280
|
||||||
|
* description: Viewport width in pixels
|
||||||
|
* - name: height
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 200
|
||||||
|
* maximum: 2160
|
||||||
|
* default: 800
|
||||||
|
* description: Viewport height in pixels
|
||||||
|
* - name: fullPage
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: boolean
|
||||||
|
* default: false
|
||||||
|
* description: Capture full scrollable page instead of viewport only
|
||||||
|
* - name: quality
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 100
|
||||||
|
* default: 80
|
||||||
|
* description: JPEG/WebP quality (ignored for PNG)
|
||||||
|
* - name: waitForSelector
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: CSS selector to wait for before capturing
|
||||||
|
* - name: deviceScale
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: number
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 3
|
||||||
|
* default: 1
|
||||||
|
* description: Device scale factor (2 = Retina)
|
||||||
|
* - name: delay
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 0
|
||||||
|
* maximum: 5000
|
||||||
|
* default: 0
|
||||||
|
* description: Extra delay in ms after page load before capturing
|
||||||
|
* - name: waitUntil
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [load, domcontentloaded, networkidle0, networkidle2]
|
||||||
|
* default: domcontentloaded
|
||||||
|
* description: Page load event to wait for before capturing
|
||||||
|
* - name: cache
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: boolean
|
||||||
|
* default: true
|
||||||
|
* description: Enable response caching (5-minute TTL)
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Screenshot image binary
|
||||||
|
* headers:
|
||||||
|
* X-Cache:
|
||||||
|
* description: Cache status
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [HIT, MISS]
|
||||||
|
* content:
|
||||||
|
* image/png:
|
||||||
|
* schema: { type: string, format: binary }
|
||||||
|
* image/jpeg:
|
||||||
|
* schema: { type: string, format: binary }
|
||||||
|
* image/webp:
|
||||||
|
* schema: { type: string, format: binary }
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request (bad URL, blocked domain, etc.)
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema: { $ref: "#/components/schemas/Error" }
|
||||||
|
* 401:
|
||||||
|
* description: Missing API key
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema: { $ref: "#/components/schemas/Error" }
|
||||||
|
* 403:
|
||||||
|
* description: Invalid API key
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema: { $ref: "#/components/schemas/Error" }
|
||||||
|
* 429:
|
||||||
|
* description: Rate or usage limit exceeded
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema: { $ref: "#/components/schemas/Error" }
|
||||||
|
* 503:
|
||||||
|
* description: Service busy (queue full)
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema: { $ref: "#/components/schemas/Error" }
|
||||||
|
* 504:
|
||||||
|
* description: Screenshot timed out
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema: { $ref: "#/components/schemas/Error" }
|
||||||
*/
|
*/
|
||||||
screenshotRouter.post("/", async (req: any, res) => {
|
// Shared handler for both GET and POST requests
|
||||||
const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, delay, waitUntil } = req.body;
|
async function handleScreenshotRequest(req: any, res: any) {
|
||||||
|
// Extract parameters from both query (GET) and body (POST)
|
||||||
|
const source = req.method === "GET" ? req.query : req.body;
|
||||||
|
|
||||||
|
const {
|
||||||
|
url,
|
||||||
|
format,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fullPage,
|
||||||
|
quality,
|
||||||
|
waitForSelector,
|
||||||
|
deviceScale,
|
||||||
|
delay,
|
||||||
|
waitUntil,
|
||||||
|
cache,
|
||||||
|
} = source;
|
||||||
|
|
||||||
if (!url || typeof url !== "string") {
|
if (!url || typeof url !== "string") {
|
||||||
res.status(400).json({ error: "Missing required parameter: url" });
|
res.status(400).json({ error: "Missing required parameter: url" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize parameters
|
||||||
|
const params = {
|
||||||
|
url,
|
||||||
|
format: format || "png",
|
||||||
|
width: width ? parseInt(width, 10) : undefined,
|
||||||
|
height: height ? parseInt(height, 10) : undefined,
|
||||||
|
fullPage: fullPage === true || fullPage === "true",
|
||||||
|
quality: quality ? parseInt(quality, 10) : undefined,
|
||||||
|
waitForSelector,
|
||||||
|
deviceScale: deviceScale ? parseFloat(deviceScale) : undefined,
|
||||||
|
delay: delay ? parseInt(delay, 10) : undefined,
|
||||||
|
waitUntil: ["load", "domcontentloaded", "networkidle0", "networkidle2"].includes(waitUntil) ? waitUntil : undefined,
|
||||||
|
cache,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await takeScreenshot({
|
// Check cache first (if not bypassed)
|
||||||
url,
|
let cacheHit = false;
|
||||||
format: format || "png",
|
if (!screenshotCache.shouldBypass(params)) {
|
||||||
width: width ? parseInt(width, 10) : undefined,
|
const cached = screenshotCache.get(params);
|
||||||
height: height ? parseInt(height, 10) : undefined,
|
if (cached) {
|
||||||
fullPage: fullPage === true || fullPage === "true",
|
res.setHeader("Content-Type", cached.contentType);
|
||||||
quality: quality ? parseInt(quality, 10) : undefined,
|
res.setHeader("Content-Length", cached.buffer.length);
|
||||||
waitForSelector,
|
res.setHeader("Cache-Control", "no-store");
|
||||||
deviceScale: deviceScale ? parseFloat(deviceScale) : undefined,
|
res.setHeader("X-Cache", "HIT");
|
||||||
delay: delay ? parseInt(delay, 10) : undefined,
|
res.send(cached.buffer);
|
||||||
waitUntil: ["load", "domcontentloaded", "networkidle0", "networkidle2"].includes(waitUntil) ? waitUntil : undefined,
|
return;
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take new screenshot
|
||||||
|
const result = await takeScreenshot(params);
|
||||||
|
|
||||||
|
// Cache the result (if not bypassed)
|
||||||
|
if (!screenshotCache.shouldBypass(params)) {
|
||||||
|
screenshotCache.put(params, result.buffer, result.contentType);
|
||||||
|
}
|
||||||
|
|
||||||
res.setHeader("Content-Type", result.contentType);
|
res.setHeader("Content-Type", result.contentType);
|
||||||
res.setHeader("Content-Length", result.buffer.length);
|
res.setHeader("Content-Length", result.buffer.length);
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.setHeader("X-Cache", "MISS");
|
||||||
res.send(result.buffer);
|
res.send(result.buffer);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error({ err: err.message, url }, "Screenshot failed");
|
logger.error({ err: err.message, url }, "Screenshot failed");
|
||||||
|
|
@ -173,4 +359,8 @@ screenshotRouter.post("/", async (req: any, res) => {
|
||||||
|
|
||||||
res.status(500).json({ error: "Screenshot failed", details: err.message });
|
res.status(500).json({ error: "Screenshot failed", details: err.message });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// Register both GET and POST routes
|
||||||
|
screenshotRouter.get("/", handleScreenshotRequest);
|
||||||
|
screenshotRouter.post("/", handleScreenshotRequest);
|
||||||
|
|
|
||||||
195
src/services/cache.ts
Normal file
195
src/services/cache.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import crypto from "crypto";
|
||||||
|
import logger from "./logger.js";
|
||||||
|
|
||||||
|
interface CacheItem {
|
||||||
|
buffer: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
timestamp: number;
|
||||||
|
lastAccessed: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScreenshotCache {
|
||||||
|
private cache = new Map<string, CacheItem>();
|
||||||
|
private ttlMs: number;
|
||||||
|
private maxSizeBytes: number;
|
||||||
|
private currentSizeBytes = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Default TTL: 5 minutes (configurable via env)
|
||||||
|
this.ttlMs = parseInt(process.env.CACHE_TTL_MS || "300000", 10);
|
||||||
|
// Default max size: 100MB (configurable via env)
|
||||||
|
this.maxSizeBytes = parseInt(process.env.CACHE_MAX_MB || "100", 10) * 1024 * 1024;
|
||||||
|
|
||||||
|
// Start cleanup interval every minute
|
||||||
|
setInterval(() => this.cleanup(), 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key from URL and screenshot parameters
|
||||||
|
*/
|
||||||
|
private generateKey(params: any): string {
|
||||||
|
// Hash all parameters that affect the screenshot result
|
||||||
|
const keyData = {
|
||||||
|
url: params.url,
|
||||||
|
format: params.format || "png",
|
||||||
|
width: params.width,
|
||||||
|
height: params.height,
|
||||||
|
fullPage: params.fullPage,
|
||||||
|
quality: params.quality,
|
||||||
|
waitForSelector: params.waitForSelector,
|
||||||
|
deviceScale: params.deviceScale,
|
||||||
|
delay: params.delay,
|
||||||
|
waitUntil: params.waitUntil,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
hash.update(JSON.stringify(keyData));
|
||||||
|
return hash.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached screenshot if available and not expired
|
||||||
|
*/
|
||||||
|
get(params: any): CacheItem | null {
|
||||||
|
const key = this.generateKey(params);
|
||||||
|
const item = this.cache.get(key);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (Date.now() - item.timestamp > this.ttlMs) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.currentSizeBytes -= item.size;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last accessed time for LRU
|
||||||
|
item.lastAccessed = Date.now();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store screenshot in cache
|
||||||
|
*/
|
||||||
|
put(params: any, buffer: Buffer, contentType: string): void {
|
||||||
|
const key = this.generateKey(params);
|
||||||
|
const size = buffer.length;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Don't cache if item is larger than 50% of max cache size
|
||||||
|
if (size > this.maxSizeBytes * 0.5) {
|
||||||
|
logger.warn({ size, maxSize: this.maxSizeBytes }, "Screenshot too large to cache");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make room if needed
|
||||||
|
this.evictToFit(size);
|
||||||
|
|
||||||
|
const item: CacheItem = {
|
||||||
|
buffer,
|
||||||
|
contentType,
|
||||||
|
timestamp: now,
|
||||||
|
lastAccessed: now,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cache.set(key, item);
|
||||||
|
this.currentSizeBytes += size;
|
||||||
|
|
||||||
|
logger.debug({
|
||||||
|
key: key.substring(0, 8),
|
||||||
|
size,
|
||||||
|
totalItems: this.cache.size,
|
||||||
|
totalSize: this.currentSizeBytes
|
||||||
|
}, "Screenshot cached");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if caching should be bypassed
|
||||||
|
*/
|
||||||
|
shouldBypass(params: any): boolean {
|
||||||
|
// Check for cache=false in params (both GET query and POST body)
|
||||||
|
return params.cache === false || params.cache === "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict items to make room for new item
|
||||||
|
*/
|
||||||
|
private evictToFit(newItemSize: number): void {
|
||||||
|
// Calculate how much space we need
|
||||||
|
const availableSpace = this.maxSizeBytes - this.currentSizeBytes;
|
||||||
|
if (availableSpace >= newItemSize) {
|
||||||
|
return; // No eviction needed
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceNeeded = newItemSize - availableSpace;
|
||||||
|
let spaceFreed = 0;
|
||||||
|
|
||||||
|
// Sort items by last accessed time (oldest first)
|
||||||
|
const items = Array.from(this.cache.entries()).sort(
|
||||||
|
([, a], [, b]) => a.lastAccessed - b.lastAccessed
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [key, item] of items) {
|
||||||
|
if (spaceFreed >= spaceNeeded) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.currentSizeBytes -= item.size;
|
||||||
|
spaceFreed += item.size;
|
||||||
|
|
||||||
|
logger.debug({
|
||||||
|
key: key.substring(0, 8),
|
||||||
|
size: item.size,
|
||||||
|
spaceFreed,
|
||||||
|
spaceNeeded
|
||||||
|
}, "Evicted cache item");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired items
|
||||||
|
*/
|
||||||
|
private cleanup(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
let expiredCount = 0;
|
||||||
|
let freedBytes = 0;
|
||||||
|
|
||||||
|
for (const [key, item] of this.cache.entries()) {
|
||||||
|
if (now - item.timestamp > this.ttlMs) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.currentSizeBytes -= item.size;
|
||||||
|
expiredCount++;
|
||||||
|
freedBytes += item.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiredCount > 0) {
|
||||||
|
logger.debug({
|
||||||
|
expiredCount,
|
||||||
|
freedBytes,
|
||||||
|
remainingItems: this.cache.size,
|
||||||
|
totalSize: this.currentSizeBytes
|
||||||
|
}, "Cache cleanup completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
items: this.cache.size,
|
||||||
|
sizeBytes: this.currentSizeBytes,
|
||||||
|
maxSizeBytes: this.maxSizeBytes,
|
||||||
|
ttlMs: this.ttlMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const screenshotCache = new ScreenshotCache();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue