From 8ae40824b44de787c249d754f63860ed6087a185 Mon Sep 17 00:00:00 2001 From: Jeremy Walker Date: Wed, 12 Aug 2026 00:23:35 +0200 Subject: [PATCH] Cache generated images in S3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendering an image costs a few seconds of headless Chrome at 2GB, and until now we paid that on every cache miss. Edge caches can't prevent that on their own: they're per-PoP, they evict the long tail, and a flood of requests for distinct URLs misses them entirely. July's bill showed what that costs when someone points a bot at it. Write each generated image through to S3 so a given URL is only ever rendered once. Cost becomes a function of how many images exist rather than how many times they're requested. Reads and writes both fail soft, so a missing bucket or policy degrades to today's behaviour rather than breaking image generation. Timestamped URLs are immutable, so their stored copy is used forever. Legacy untimestamped URLs point at mutable content, so a stored copy is only reused for 24h — matching the Cache-Control we already return. Also set explicit navigation and selector timeouts. puppeteer defaults both to 30s, which is longer than the Lambda's own 20s timeout, so a hung render burned the full 20s at 2GB rather than failing fast. Since a render can now fail instead of taking the container down with it, the browser is closed in a finally block so it can't leak into the next warm invocation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018iQj4EdMfNsWP6NtFTqwUU --- README.md | 44 +++++- index.js | 185 ++++++++++++++++------ package-lock.json | 396 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 4 files changed, 580 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index dc7d500..dc0304e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,48 @@ ![Tests][tests-badge] -TODO +Generates the social/OG preview images for solutions and profiles. + +A request for e.g. `/tracks/ruby/exercises/bob/solutions/ihid-1720000000.jpg` +arrives via CloudFront at this Lambda's Function URL. The Lambda runs headless +Chrome against the corresponding page on exercism.org +(`/images/solutions/ruby/bob/ihid`), screenshots the `#image-content` element, +and returns it as a JPEG. + +## Caching + +Generated images are written through to S3, so any given URL is only ever +rendered once. This matters because rendering costs a few seconds of headless +Chrome at 2GB, while serving a stored copy costs an S3 GET — around 300x less. + +CDN edge caches alone don't give us that guarantee: they're per-PoP, they evict +the long tail (most images are fetched only a handful of times ever), and a +flood of requests for *distinct* URLs misses them entirely. Writing through to +S3 makes cost a function of how many images exist rather than how many times +they're requested. + +URLs ending in `-${timestamp}.jpg` address a version that will never change, so +their stored copy is used indefinitely. Legacy URLs without a timestamp address +mutable content, so a stored copy is only reused for 24 hours — matching the +`Cache-Control` we hand back to the CDN. + +If S3 is unreachable or the Lambda lacks permission, both reads and writes fail +soft and the image is generated as normal. + +## Configuration + +| Variable | Default | Purpose | +| --- | --- | --- | +| `IMAGE_BUCKET` | `exercism-v3-assets` | Bucket holding generated images | +| `IMAGE_KEY_PREFIX` | `generated-images` | Key prefix within that bucket | +| `NAVIGATION_TIMEOUT_MS` | `6000` | Page navigation timeout | +| `SELECTOR_TIMEOUT_MS` | `6000` | Timeout waiting for the content selector | + +The two timeouts must stay comfortably below the Lambda's own timeout (20s). +puppeteer defaults both to 30s, which is *longer*, meaning a hung render burned +the full 20s at 2GB instead of failing fast. + +The Lambda's execution role needs `s3:GetObject` and `s3:PutObject` on +`arn:aws:s3:::${IMAGE_BUCKET}/${IMAGE_KEY_PREFIX}/*`. [tests-badge]: https://github.com/exercism/image-generator/workflows/Test/badge.svg diff --git a/index.js b/index.js index 3654891..4d7c5b0 100644 --- a/index.js +++ b/index.js @@ -1,17 +1,41 @@ const fs = require("fs"); +const crypto = require("crypto"); const puppeteer = require("puppeteer-core"); const chromium = require("@sparticuz/chromium"); +const { + S3Client, + GetObjectCommand, + PutObjectCommand, +} = require("@aws-sdk/client-s3"); const imagePath = "/tmp/screenshot.jpg"; const baseUrl = "https://exercism.org"; +// Generating an image costs a few seconds of headless Chrome at 2GB, so we only +// ever want to pay for it once per distinct URL. CDN edge caches can't give us +// that on their own: they're per-PoP, they evict the long tail (most images are +// fetched a handful of times ever), and a flood of distinct URLs misses them +// entirely. Writing through to S3 makes the cost a function of how many images +// exist rather than how many times they're requested. +const bucket = process.env.IMAGE_BUCKET || "exercism-v3-assets"; +const keyPrefix = process.env.IMAGE_KEY_PREFIX || "generated-images"; + +const s3 = new S3Client({}); + +// These must stay comfortably under the Lambda's 20s timeout. puppeteer +// defaults both to 30s, which is longer, so before this a render that hung +// burned the full 20s at 2GB rather than failing fast. +const navigationTimeout = parseInt(process.env.NAVIGATION_TIMEOUT_MS || "6000", 10); +const selectorTimeout = parseInt(process.env.SELECTOR_TIMEOUT_MS || "6000", 10); + +const legacyMaxAge = 86400; + const solutionRegex = /^\/tracks\/(?.+?)\/exercises\/(?.+?)\/solutions\/(?.+?)(?:-\d{10})?\.jpg$/; const profileRegex = /^\/profiles\/(?.+?)(?:-\d{10})?\.jpg$/; -const crypto = require("crypto"); - function rawPathToScreenshotData(rawPath) { - if ((solutionMatch = solutionRegex.exec(rawPath))) { + const solutionMatch = solutionRegex.exec(rawPath); + if (solutionMatch) { const { track_slug, exercise_slug, user_handle } = solutionMatch.groups; return { @@ -21,7 +45,8 @@ function rawPathToScreenshotData(rawPath) { }; } - if ((profileMatch = profileRegex.exec(rawPath))) { + const profileMatch = profileRegex.exec(rawPath); + if (profileMatch) { const { user_handle } = profileMatch.groups; return { @@ -34,28 +59,106 @@ function rawPathToScreenshotData(rawPath) { throw new Error(`Could not map raw path '${rawPath}' to image URL.`); } -exports.handler = async (event) => { +// URLs ending in -${timestamp}.jpg address a version that will never change, so +// they can be cached forever. Legacy URLs without one address mutable content. +function cacheMetadata(rawPath) { + const match = rawPath.match(/-(\d{10})\.\w+$/); + const isTimestamped = !!match; + + return { + isTimestamped, + cacheControl: isTimestamped + ? "public, max-age=31536000, immutable" + : `public, max-age=${legacyMaxAge}`, + lastModified: isTimestamped + ? new Date(parseInt(match[1], 10) * 1000).toUTCString() + : new Date().toUTCString(), + }; +} + +function s3Key(rawPath) { + return `${keyPrefix}${rawPath}`; +} + +function imageResponse(imageBuffer, { cacheControl, lastModified }) { + const etag = crypto.createHash("md5").update(imageBuffer).digest("hex"); + + return { + statusCode: 200, + body: imageBuffer.toString("base64"), + headers: { + "Content-Type": "image/jpg", + "Cache-Control": cacheControl, + "Last-Modified": lastModified, + "Etag": `W/"${etag}"`, + }, + isBase64Encoded: true, + }; +} + +// A cache problem should never stop us serving an image, so every failure here +// falls through to generating one. +async function fetchFromS3(key, { isTimestamped }) { try { - const { url, imageSelector, waitForSelector } = rawPathToScreenshotData( - event.rawPath + const object = await s3.send( + new GetObjectCommand({ Bucket: bucket, Key: key }) ); - const browser = await puppeteer.launch({ - executablePath: await chromium.executablePath(), - headless: chromium.headless, - ignoreHTTPSErrors: true, - defaultViewport: { ...chromium.defaultViewport, deviceScaleFactor: 2 }, - args: [ - ...chromium.args, - "--hide-scrollbars", - "--disable-web-security", - "--high-dpi-support=1", - ], - }); + // Legacy URLs point at content that can change, so a stored copy is only + // good for as long as we'd have let a CDN hold onto it. + if (!isTimestamped) { + const age = (Date.now() - object.LastModified.getTime()) / 1000; + if (age > legacyMaxAge) return null; + } + + return Buffer.from(await object.Body.transformToByteArray()); + } catch (err) { + if (err.name !== "NoSuchKey" && err.name !== "NotFound") { + console.error(`Failed reading ${key} from S3: ${err.message}`); + } + return null; + } +} + +async function writeToS3(key, imageBuffer, cacheControl) { + try { + await s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: imageBuffer, + ContentType: "image/jpg", + CacheControl: cacheControl, + }) + ); + } catch (err) { + console.error(`Failed writing ${key} to S3: ${err.message}`); + } +} + +async function generateImage({ url, imageSelector, waitForSelector }) { + const browser = await puppeteer.launch({ + executablePath: await chromium.executablePath(), + headless: chromium.headless, + ignoreHTTPSErrors: true, + defaultViewport: { ...chromium.defaultViewport, deviceScaleFactor: 2 }, + args: [ + ...chromium.args, + "--hide-scrollbars", + "--disable-web-security", + "--high-dpi-support=1", + ], + }); + + // Now that a render can fail rather than take the whole container down with + // it, the browser has to be closed on the way out or it leaks into the next + // warm invocation. + try { const page = await browser.newPage(); + page.setDefaultNavigationTimeout(navigationTimeout); await page.goto(url); - await page.waitForSelector(waitForSelector); + await page.waitForSelector(waitForSelector, { timeout: selectorTimeout }); const image = await page.$(imageSelector); await image.screenshot({ @@ -63,37 +166,29 @@ exports.handler = async (event) => { type: "jpeg", quality: 80, }); - await browser.close(); - const imageBuffer = fs.readFileSync(imagePath); - const etag = crypto.createHash("md5").update(imageBuffer).digest("hex"); + return fs.readFileSync(imagePath); + } finally { + await browser.close(); + } +} - // Try to extract the 10-digit timestamp from the URL - // if it ends with -${timestamp}.jpg - const match = event.rawPath.match(/-(\d{10})\.\w+$/); +exports.handler = async (event) => { + try { + const screenshotData = rawPathToScreenshotData(event.rawPath); + const metadata = cacheMetadata(event.rawPath); + const key = s3Key(event.rawPath); - const isTimestamped = !!match; - const cacheControl = isTimestamped - ? "public, max-age=31536000, immutable" // New URL containing a timestamp that will never change - : "public, max-age=86400"; // Legacy URL without a timestamp + const cached = await fetchFromS3(key, metadata); + if (cached) return imageResponse(cached, metadata); - // Use extracted timestamp if available, else use current time - const lastModified = isTimestamped - ? new Date(parseInt(match[1], 10) * 1000).toUTCString() - : new Date().toUTCString(); + const imageBuffer = await generateImage(screenshotData); + await writeToS3(key, imageBuffer, metadata.cacheControl); - return { - statusCode: 200, - body: fs.readFileSync(imagePath, { encoding: "base64" }), - headers: { - "Content-Type": "image/jpg", - "Cache-Control": cacheControl, - "Last-Modified": lastModified, - "Etag": `W/"${etag}"` - }, - isBase64Encoded: true, - }; + return imageResponse(imageBuffer, metadata); } catch (err) { + console.error(err); + return { statusCode: 500, body: err.message, diff --git a/package-lock.json b/package-lock.json index cfb2aa7..8f22d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,319 @@ "version": "0.0.1", "license": "GNU AFFERO GPLV3", "dependencies": { + "@aws-sdk/client-s3": "^3.1108.0", "@sparticuz/chromium": "^121.0.0", "puppeteer-core": "^21.10.0" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.27.tgz", + "integrity": "sha512-insWOqKKNUrbN/dohEG7BJ0U5GkyqhjbMb/NHNaLUtq+7my2M8C4EnZZZoxMmXRqCC+P9dEr+KyJA2JGGzoKLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1108.0.tgz", + "integrity": "sha512-prdothEAFE1G8H0s0+zFGuNZdSj+Acg/siB1dFxPS181op9+hJ1GLr+b2anJch4B9a/xbWy7k8eG/enH3cNSjQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.27", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-node": "^3.972.79", + "@aws-sdk/middleware-sdk-s3": "^3.972.73", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", + "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@aws-sdk/xml-builder": "^3.972.38", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", + "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", + "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", + "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-login": "^3.972.75", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", + "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", + "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-ini": "^3.973.13", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", + "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", + "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/token-providers": "3.1108.0", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", + "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.73.tgz", + "integrity": "sha512-oy7sRA5HvHcAvkcKX6F8RI240jcOf3c8y/Gqjs9qemIibdKQqGBIi0uwa+47ZRYqGLpdEO28TQU4G73yUzo06Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", + "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", + "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", + "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", + "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", + "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@puppeteer/browsers": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.9.1.tgz", @@ -33,6 +342,87 @@ "node": ">=16.3.0" } }, + "node_modules/@smithy/core": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.32.0.tgz", + "integrity": "sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.10.0.tgz", + "integrity": "sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@sparticuz/chromium": { "version": "121.0.0", "resolved": "https://registry.npmjs.org/@sparticuz/chromium/-/chromium-121.0.0.tgz", @@ -144,6 +534,12 @@ "node": ">=10.0.0" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", diff --git a/package.json b/package.json index 168a348..159eb51 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "main": "index.js", "license": "GNU AFFERO GPLV3", "dependencies": { + "@aws-sdk/client-s3": "^3.1108.0", "@sparticuz/chromium": "^121.0.0", "puppeteer-core": "^21.10.0" }